[feat] 공급사 포털: 협상완료 부가정보(select·종료 동의폼)·상태 라벨 통일·예아니오 Enter=예·목록 결과열·재협상 요청/철회 API·테스트

This commit is contained in:
Mina Choi 2026-07-24 14:18:52 +09:00
parent cfee6e89d0
commit d7c4e86f30
46 changed files with 1715 additions and 270 deletions

View File

@ -173,6 +173,7 @@ class quotations(MAIN_BASE):
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)
preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷) preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷)
close_reason = Column(SmallInteger, nullable=True) # 마감 사유(CloseReason). 재협상 요청 자격 판정에 읽는다
equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부 equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON) equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
@ -180,6 +181,28 @@ class quotations(MAIN_BASE):
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class notifications(MAIN_BASE):
# company.notifications (담당자 인박스). 포털은 재협상 요청 알림을 만들기 위해서만 쓴다(조회는 negodata).
# company 스키마 전용 DBType 이 없어 USER 커넥션을 재사용한다(물리 DB 동일).
@staticmethod
def DBType():
return DBType.USER.value
__tablename__ = "notifications"
__table_args__ = {"schema": "company"}
notification_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
user_id = Column(UUID(as_uuid=True), nullable=False) # 수신자(company.users.user_id) = 견적 작성자
type = Column(SmallInteger, nullable=False) # NotificationType
ref_qt_id = Column(UUID(as_uuid=True), nullable=True)
ref_session_id = Column(UUID(as_uuid=True), nullable=True)
data = Column(JSONB, nullable=True) # 렌더 스냅샷(공급사명·사유·희망가 등)
read_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"))
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')"))
deleted = Column(Boolean, nullable=False, server_default=text("false"))
class quotation_settings(MAIN_BASE): class quotation_settings(MAIN_BASE):
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값). # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값).
@staticmethod @staticmethod

View File

@ -130,6 +130,41 @@ class QuotationStatus(Enum):
CLOSED = 3 # 견적마감 CLOSED = 3 # 견적마감
class CloseReason(Enum):
"""견적 마감 사유. quotation.quotations.close_reason
낙찰(AWARDED) OPEN_* 낙찰자 미정으로 마감된 '결렬' 공급사 재협상 요청 대상."""
AWARDED = 1 # 낙찰
OPEN_PRICE = 5 # 개찰: 낙찰 기준 미달
OPEN_EQUAL = 6 # 개찰: 동가
OPEN_NOSHOW = 7 # 개찰: 전원 미응찰
OPEN_REJECT = 8 # 개찰: 협상거부 존재
# 재협상 요청 가능한 마감 사유(낙찰 건은 제외).
RENEGOTIABLE_CLOSE_REASONS = (
CloseReason.OPEN_PRICE.value,
CloseReason.OPEN_EQUAL.value,
CloseReason.OPEN_NOSHOW.value,
CloseReason.OPEN_REJECT.value,
)
class RenegotiationStatus(Enum):
"""sessions.custom.renegotiation.status — 공급사 재협상 요청 상태(IMK #15)."""
PENDING = 1 # 접수, 담당자 심사 대기
APPROVED = 2 # 승인 — 다음 라운드 생성됨
REJECTED = 3 # 반려
CANCELED = 4 # 공급사 철회
class NotificationType(Enum):
"""company.notifications.type — negodata 담당자 인박스. 포털에서 만드는 건 재협상 요청뿐."""
RENEGO_REQUESTED = 5
class ChatSender(Enum): class ChatSender(Enum):
"""채팅 발신자 코드. negotiation.chats.sender """ """채팅 발신자 코드. negotiation.chats.sender """

View File

@ -1,12 +1,13 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple from typing import Tuple
from sqlalchemy import case, func, nulls_last, select, update from sqlalchemy import case, cast, func, nulls_last, or_, select, text, update
from sqlalchemy.dialects.postgresql import JSONB
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 items, quotations, sessions from common.database.model.models import items, quotations, sessions
from common.enums import ErrorType, SessionStatus from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
from common.logger import LOG from common.logger import LOG
@ -14,11 +15,11 @@ from common.logger import LOG
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용). # 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
class ISessionCRUD(ABC): class ISessionCRUD(ABC):
@abstractmethod @abstractmethod
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]: async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
pass pass
@abstractmethod @abstractmethod
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]: async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
pass pass
@abstractmethod @abstractmethod
@ -45,20 +46,59 @@ class ISessionCRUD(ABC):
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType: async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
pass pass
@abstractmethod
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
try:
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, 0
top = rows[0][0] if rows and rows[0] else None
return ErrorType.SUCCESS, int(top or 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
pass
@abstractmethod
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
pass
class SessionCRUD(ISessionCRUD): class SessionCRUD(ISessionCRUD):
@staticmethod @staticmethod
def __filters(supplier_id, status, qt_type): def __filters(supplier_id, status, qt_type, keyword=None, result=None):
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712 conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
if status is not None: if status is not None:
conds.append(sessions.status == status) conds.append(sessions.status == status)
if qt_type is not None: if qt_type is not None:
conds.append(sessions.qt_type == qt_type) conds.append(sessions.qt_type == qt_type)
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
# ILIKE 와일드카드(%,_)는 escape 해 사용자 입력이 패턴으로 새지 않게 한다.
if keyword and keyword.strip():
kw = keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like = f"%{kw}%"
conds.append(or_(sessions.qt_number.ilike(like), items.name.ilike(like), items.code.ilike(like)))
# 결과(SessionResult) 필터 — _to_result 파생 규칙을 SQL WHERE 로 그대로 복제(집계·필터 일치용).
# 1=낙찰 2=미낙찰 3=결렬(개찰). 전부 견적 마감(CLOSED) 이 전제.
if result in (1, 2, 3):
conds.append(quotations.status == QuotationStatus.CLOSED.value)
if result == 1:
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
conds.append(quotations.preferred_sp_id == sessions.supplier_id)
elif result == 2:
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
conds.append(or_(quotations.preferred_sp_id.is_(None), quotations.preferred_sp_id != sessions.supplier_id))
else:
conds.append(quotations.close_reason.in_(RENEGOTIABLE_CLOSE_REASONS))
return conds return conds
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]: async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
try: try:
conds = self.__filters(supplier_id, status, qt_type) conds = self.__filters(supplier_id, status, qt_type, keyword, result)
# 정렬 규칙: # 정렬 규칙:
# - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬). # - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬).
@ -91,6 +131,11 @@ class SessionCRUD(ISessionCRUD):
items.model_name, items.model_name,
items.manufacturer, items.manufacturer,
sessions.custom, sessions.custom,
quotations.status, # 재협상 요청 자격 판정용(마감 여부)
quotations.close_reason, # 개찰(결렬) 사유
quotations.round,
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
) )
.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)
@ -107,9 +152,9 @@ class SessionCRUD(ISessionCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [] return ErrorType.DB_RUN_FAILED, []
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]: async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
try: try:
conds = self.__filters(supplier_id, status, qt_type) conds = self.__filters(supplier_id, status, qt_type, keyword, result)
query = ( query = (
select(func.count()) select(func.count())
.select_from(sessions) .select_from(sessions)
@ -179,6 +224,32 @@ class SessionCRUD(ISessionCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
try:
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, 0
top = rows[0][0] if rows and rows[0] else None
return ErrorType.SUCCESS, int(top or 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
# sessions.custom 부분 갱신(기존 키 보존). 부가정보와 재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
try:
query = (
update(sessions)
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
.values(custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)))
)
return await DB_SESSION_MNG.add(cdb, query)
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: async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
# 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드). # 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드).
try: try:

View File

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import Field from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -15,6 +17,10 @@ class ListItem(WebPacketProtocol):
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") custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict")
renegotiable: bool = Field(False, description="재협상 요청 가능 여부 — 낙찰 없이 마감(개찰)된 마지막 차수이고 대기 중 요청이 없을 때만 True")
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
class Res_SessionList(Res_WebPacketProtocol): class Res_SessionList(Res_WebPacketProtocol):
@ -42,3 +48,13 @@ class Req_ExtraInfo(WebPacketProtocol):
class Res_ExtraInfo(Res_WebPacketProtocol): class Res_ExtraInfo(Res_WebPacketProtocol):
session_id: str = Field("", description="부가정보 저장된 세션 uuid") session_id: str = Field("", description="부가정보 저장된 세션 uuid")
class Req_Renegotiation(WebPacketProtocol):
reason: str = Field("", max_length=255, description="재협상 요청 사유(프리셋 라벨 또는 직접 입력)")
desired_price: Optional[int] = Field(None, description="희망 공급가(원). 담당자 판단 근거로만 쓰인다")
class Res_Renegotiation(Res_WebPacketProtocol):
session_id: str = Field("", description="요청이 기록된 세션 uuid")
status: int = Field(0, description="요청 상태(RenegotiationStatus): 1=심사중 2=승인 3=반려 4=철회")

View File

@ -6,7 +6,16 @@ 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_ExtraInfo, Req_Reject, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList from .protocol import (
Req_ExtraInfo,
Req_Reject,
Req_Renegotiation,
Res_ExtraInfo,
Res_Participate,
Res_Reject,
Res_Renegotiation,
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"}})
@ -26,9 +35,11 @@ async def list_sessions(
order: Optional[str] = Query(None, description="마감일 전체 정렬: asc(임박순)/desc(여유순). 미지정 시 기본 그룹 정렬('할 일' 우선 → 종료는 하단·최근순). 지정하면 그룹 없이 전체를 마감 기준으로 정렬."), order: Optional[str] = Query(None, description="마감일 전체 정렬: asc(임박순)/desc(여유순). 미지정 시 기본 그룹 정렬('할 일' 우선 → 종료는 하단·최근순). 지정하면 그룹 없이 전체를 마감 기준으로 정렬."),
page: int = Query(1, ge=1, description="페이지 (1부터)"), page: int = Query(1, ge=1, description="페이지 (1부터)"),
page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"), page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"),
keyword: Optional[str] = Query(None, description="검색어 — 견적번호·상품명·상품코드 부분일치(대소문자 무시)"),
result: Optional[int] = Query(None, description="결과 필터(SessionResult): 1=낙찰 2=미낙찰 3=결렬(개찰). 미지정 시 전체"),
): ):
return RemoveNoneResponse( return RemoveNoneResponse(
await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size) await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size, keyword, result)
) )
@ -77,3 +88,38 @@ async def save_extra_info(
service: NegotiationService = Depends(), service: NegotiationService = Depends(),
): ):
return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req)) return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req))
@router.post(
path="/session/{session_id}/renegotiation",
response_model=Res_Renegotiation,
summary="재협상 요청",
description="낙찰 없이 마감된(개찰) 건에 대해 공급사가 재협상을 요청한다. 담당자 승인 시 다음 라운드가 생성된다. 본인 공급사의 마지막 라운드 세션만 허용.",
)
async def request_renegotiation(
req: Req_Renegotiation,
session_id: str = Path(..., description="협상 세션 uuid"),
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(
await service.request_renegotiation(user_info, credentials.credentials, session_id, req)
)
@router.delete(
path="/session/{session_id}/renegotiation",
response_model=Res_Renegotiation,
summary="재협상 요청 철회",
description="심사 대기(PENDING) 중인 본인 요청을 철회한다.",
)
async def cancel_renegotiation(
session_id: str = Path(..., description="협상 세션 uuid"),
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(
await service.cancel_renegotiation(user_info, credentials.credentials, session_id)
)

View File

@ -4,12 +4,31 @@ from datetime import datetime, timezone
from fastapi import Depends from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, sessions from common.database.model.models import chats, notifications, sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus from common.enums import (
CloseReason,
DBWRType,
ErrorType,
NotificationType,
QuotationStatus,
RENEGOTIABLE_CLOSE_REASONS,
RenegotiationStatus,
SessionStatus,
)
from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList from router.v1.negotiation.protocol import (
ListItem,
Req_ExtraInfo,
Req_Renegotiation,
Res_ExtraInfo,
Res_Participate,
Res_Reject,
Res_Renegotiation,
Res_SessionList,
)
from services.auth_service import AuthService from services.auth_service import AuthService
@ -32,7 +51,7 @@ class NegotiationService:
self.session_crud = session_crud self.session_crud = session_crud
self.chat_crud = chat_crud self.chat_crud = chat_crud
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList: async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int, keyword: str = None, result: int = None) -> Res_SessionList:
res = Res_SessionList() res = Res_SessionList()
# 1) 인증 (활성 + 저장된 access 토큰 대조) # 1) 인증 (활성 + 저장된 access 토큰 대조)
@ -48,7 +67,7 @@ class NegotiationService:
err_type, rows = await DB_SESSION_MNG.execute_lambda( err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), sessions.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size), lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size, keyword, result),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -58,27 +77,22 @@ class NegotiationService:
err_type, total = await DB_SESSION_MNG.execute_lambda( err_type, total = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), sessions.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type), lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type, keyword, result),
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
res.items = [ # 같은 견적번호(체인)의 최대 차수 — 이미 다음 라운드가 있으면 재협상 요청 대상이 아니다.
ListItem( max_rounds: dict = {}
session_id=str(r[0]), for number in {r[3] for r in rows if r[3]}:
session_status=r[1], _e, mx = await DB_SESSION_MNG.execute_lambda(
qt_type=r[2], sessions.DBType(), DBWRType.DB_READ.value,
qt_number=r[3], lambda s, n=number: self.session_crud.chain_max_round(s, n),
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
item_code=r[5] or "",
item_name=r[6] or "",
model_name=r[7] or "",
maker_name=r[8] or "",
custom=r[9] or {},
) )
for r in rows max_rounds[number] = mx or 0
]
res.items = [self._to_list_item(r, max_rounds) for r in rows]
res.total = total res.total = total
res.page = page res.page = page
res.page_size = page_size res.page_size = page_size
@ -134,6 +148,190 @@ class NegotiationService:
res.session_id = str(session_id) res.session_id = str(session_id)
return res return res
@staticmethod
def _to_list_item(r, max_rounds: dict) -> ListItem:
"""세션 행 → 목록 아이템. 재협상 요청 가능 여부는 서버가 판정해 내려준다(프론트가 규칙을 몰라도 되게)."""
custom = r[9] or {}
renego = custom.get("renegotiation") or {}
status = renego.get("status") or 0
is_last_round = (r[12] or 0) >= max_rounds.get(r[3], 0)
renegotiable = (
r[10] == QuotationStatus.CLOSED.value
and r[11] in RENEGOTIABLE_CLOSE_REASONS
and is_last_round
and status
not in (
RenegotiationStatus.PENDING.value,
RenegotiationStatus.APPROVED.value,
RenegotiationStatus.REJECTED.value,
)
)
return ListItem(
session_id=str(r[0]),
session_status=r[1],
qt_type=r[2],
qt_number=r[3],
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
item_code=r[5] or "",
item_name=r[6] or "",
model_name=r[7] or "",
maker_name=r[8] or "",
custom=custom,
renegotiable=renegotiable,
renegotiation_status=status,
renegotiation_memo=renego.get("memo") or "",
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
)
@staticmethod
def _to_result(qt_status, close_reason, winner_id, my_id) -> int:
"""공급사 관점 협상 결과(SessionResult). 견적 마감 전이면 0(미정).
낙찰 건은 낙찰자가 나면 1(낙찰)·아니면 2(미낙찰), 개찰(OPEN_*) 마감은 3(결렬=재협상 대상)."""
if qt_status != QuotationStatus.CLOSED.value:
return 0
if close_reason == CloseReason.AWARDED.value:
return 1 if winner_id is not None and str(winner_id) == str(my_id) else 2
if close_reason in RENEGOTIABLE_CLOSE_REASONS:
return 3
return 0
async def request_renegotiation(
self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_Renegotiation
) -> Res_Renegotiation:
"""결렬(개찰) 마감 건에 대해 공급사가 재협상을 요청한다(IMK #15).
전용 테이블 없이 sessions.custom.renegotiation 기록하고, 견적 작성자에게 알림을 남긴다."""
res = Res_Renegotiation()
err_type, info, sess, quote = await self._load_renegotiable(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 심사 대기·승인·반려 건은 재요청을 막는다(전용 테이블이 없어 유니크 대신 여기서 검증).
# 반려는 담당자가 이미 판단한 결과라 같은 건으로 다시 올릴 수 없다. 철회(CANCELED)만 재요청 허용.
current = (sess.custom or {}).get("renegotiation") or {}
if current.get("status") in (
RenegotiationStatus.PENDING.value,
RenegotiationStatus.APPROVED.value,
RenegotiationStatus.REJECTED.value,
):
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
payload = {
"status": RenegotiationStatus.PENDING.value,
"reason": (req.reason or "").strip(),
"desired_price": req.desired_price,
"requested_at": datetime.now(timezone.utc).isoformat(),
}
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.merge_session_custom(
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": payload}
)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
await self._notify_renegotiation(quote, sess, info, payload)
res.session_id = str(sess.session_id)
res.status = RenegotiationStatus.PENDING.value
return res
async def cancel_renegotiation(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Renegotiation:
"""공급사가 자기 요청을 철회한다. 심사 대기(PENDING) 중에만 가능."""
res = Res_Renegotiation()
err_type, info, sess, _quote = await self._load_renegotiable(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
current = (sess.custom or {}).get("renegotiation") or {}
if current.get("status") != RenegotiationStatus.PENDING.value:
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
patch = {**current, "status": RenegotiationStatus.CANCELED.value}
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.merge_session_custom(
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": patch}
)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.session_id = str(sess.session_id)
res.status = RenegotiationStatus.CANCELED.value
return res
async def _load_renegotiable(self, user_info: UserInfo, access_token: str, session_id_str: str):
"""재협상 요청 자격 검증 — 인증 → 본인 세션 → 결렬(개찰) 마감 → 마지막 라운드.
성공 (SUCCESS, info, sess, quote)."""
err_type, info = await self.auth.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
return err_type, None, None, None
try:
session_id = uuid.UUID(session_id_str)
except (ValueError, TypeError):
return ErrorType.NEGO_NOT_FOUND, None, None, None
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:
return ErrorType.NEGO_NOT_FOUND, None, None, None
if str(sess.supplier_id) != info.supplier_id:
return ErrorType.NEGO_FORBIDDEN, None, None, None
err_type, quote = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
if err_type != ErrorType.SUCCESS or quote is None:
return ErrorType.NEGO_NOT_FOUND, None, None, None
# 낙찰됐거나 아직 진행 중인 건은 요청 대상이 아니다.
if quote.status != QuotationStatus.CLOSED.value or quote.close_reason not in RENEGOTIABLE_CLOSE_REASONS:
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
# 이미 다음 라운드가 만들어졌으면 요청할 이유가 없다.
_e, max_round = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.chain_max_round(s, quote.number),
)
if max_round and quote.round < max_round:
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
return ErrorType.SUCCESS, info, sess, quote
async def _notify_renegotiation(self, quote, sess, info, payload: dict) -> None:
"""견적 작성자 인박스에 재협상 요청 알림을 남긴다. 부가 효과라 실패해도 본 흐름을 막지 않는다."""
notif = notifications(
user_id=quote.user_id,
type=NotificationType.RENEGO_REQUESTED.value,
ref_qt_id=quote.qt_id,
ref_session_id=sess.session_id,
data={
"supplier_name": info.supplier_name,
"qt_number": quote.number,
"qt_round": quote.round,
"reason": payload.get("reason"),
"desired_price": payload.get("desired_price"),
},
)
err = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)],
)
if err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[renego] 알림 기록 실패 qt={quote.qt_id} session={sess.session_id}")
async def _is_after_summary(self, session_id) -> bool: async def _is_after_summary(self, session_id) -> bool:
"""마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지.""" """마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지."""
err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda( err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda(

View File

@ -177,6 +177,56 @@ async def test_list_requires_auth(client):
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403) assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
# ---- 검색(keyword) ----------------------------------------------------------
async def test_search_by_qt_number_and_item_code(client, nego_seed):
"""검증: 견적번호/상품코드가 같은 값(PYTESTNEGO-B)으로 검색.
기대결과: B 1건만, total 1(카운트도 같은 필터 적용)."""
token = await _login_token(client)
body = (await _list(client, token, keyword=f"{MARK}B")).json()
assert body["total"] == 1
assert [i["item_code"] for i in body["items"]] == [f"{MARK}B"]
async def test_search_by_item_name(client, nego_seed):
"""검증: 상품명 일부('상품 A')로 검색.
기대결과: A 1건만."""
token = await _login_token(client)
body = (await _list(client, token, keyword="상품 A")).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A"}
async def test_search_prefix_matches_all_own(client, nego_seed):
"""검증: 공통 prefix(PYTESTNEGO)로 검색.
기대결과: 본인 공급사 3 전부( 공급사 X 제외 유지)."""
token = await _login_token(client)
body = (await _list(client, token, keyword=MARK.rstrip("-"))).json()
assert body["total"] == 3
async def test_search_case_insensitive(client, nego_seed):
"""검증: 소문자로 검색(pytestnego-c).
기대결과: ILIKE 대소문자 무시하고 C 매칭."""
token = await _login_token(client)
body = (await _list(client, token, keyword=f"{MARK}c".lower())).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}C"}
async def test_search_no_match_returns_empty(client, nego_seed):
"""검증: 어디에도 없는 검색어.
기대결과: 0, total 0."""
token = await _login_token(client)
body = (await _list(client, token, keyword="존재하지않는검색어zzz")).json()
assert body["total"] == 0 and body["items"] == []
async def test_search_wildcard_is_escaped(client, nego_seed):
"""검증: ILIKE 와일드카드('%')를 그대로 검색 — 패턴으로 새면 전건 매칭될 위험.
기대결과: escape 되어 리터럴 '%' 취급 매칭 0."""
token = await _login_token(client)
body = (await _list(client, token, keyword="%")).json()
assert body["total"] == 0
# ---- 참여 ------------------------------------------------------------------- # ---- 참여 -------------------------------------------------------------------
async def test_participate_success(client, nego_seed, db_engine): async def test_participate_success(client, nego_seed, db_engine):
token = await _login_token(client) token = await _login_token(client)
@ -294,3 +344,76 @@ async def test_reject_requires_auth(client, nego_seed):
sid = nego_seed["sids"]["B"] sid = nego_seed["sids"]["B"]
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"}) r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
assert r.status_code in (401, 403) assert r.status_code in (401, 403)
# ---- 결과 필터(result) ------------------------------------------------------
# 마감(CLOSED) + 마감사유/낙찰자로 낙찰(1)·미낙찰(2)·결렬(3)을 만들고 result= 로 거른다.
# nego_seed 의 공급사/로그인을 재사용하고, MARK prefix 라 픽스처 teardown 이 함께 정리한다.
async def _seed_result_row(engine, *, supplier_id, code, close_reason, winner_id):
import uuid as _uuid
item_id, qt_id, session_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 'M', '제조사')"),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}"},
)
await conn.execute(
text("INSERT INTO quotation.quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
" preferred_sp_id, round, start_time, end_time) VALUES "
"(:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 2, 3, :cr, "
" :win, 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "cr": close_reason, "win": winner_id},
)
await conn.execute(
text("INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, bid_price, end_time) VALUES "
"(:sid, :qid, :iid, :sup, :num, 1, 2, 100000, 3, 95000, now() - make_interval(hours => 1))"),
{"sid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id, "num": f"{MARK}{code}"},
)
@pytest_asyncio.fixture
async def result_rows(nego_seed, db_engine):
"""nego_seed 공급사에 낙찰/미낙찰/결렬 각 1건을 추가한다(개찰 5=OPEN_PRICE, 1=AWARDED)."""
sup = nego_seed["supplier_id"]
await _seed_result_row(db_engine, supplier_id=sup, code="RWON", close_reason=1, winner_id=sup) # 낙찰(나)
await _seed_result_row(db_engine, supplier_id=sup, code="RLOST", close_reason=1, winner_id=uuid.uuid4()) # 미낙찰(남)
await _seed_result_row(db_engine, supplier_id=sup, code="ROPEN", close_reason=5, winner_id=None) # 결렬(개찰)
return nego_seed
async def test_result_filter_won(client, result_rows):
"""검증: result=1(낙찰)로 필터. 기대결과: 낙찰 건만, total=1."""
token = await _login_token(client)
body = (await _list(client, token, result=1)).json()
assert body["total"] == 1
assert body["items"][0]["item_code"] == f"{MARK}RWON"
assert body["items"][0]["result"] == 1
async def test_result_filter_lost(client, result_rows):
"""검증: result=2(미낙찰)로 필터. 기대결과: 미낙찰 건만."""
token = await _login_token(client)
body = (await _list(client, token, result=2)).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}RLOST"}
assert body["items"][0]["result"] == 2
async def test_result_filter_open(client, result_rows):
"""검증: result=3(결렬)로 필터. 기대결과: 개찰 결렬 건만 + 재협상 대상(renegotiable=True)."""
token = await _login_token(client)
body = (await _list(client, token, result=3)).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}ROPEN"}
assert body["items"][0]["result"] == 3
assert body["items"][0]["renegotiable"] is True
async def test_result_filter_composes_with_paging(client, result_rows):
"""검증: 결과 필터가 total(페이징)에 반영. 기대결과: result=1 이면 total=1(전체 목록과 별개)."""
token = await _login_token(client)
all_total = (await _list(client, token)).json()["total"]
won_total = (await _list(client, token, result=1)).json()["total"]
assert won_total == 1 and all_total > won_total

View File

@ -0,0 +1,215 @@
"""공급사 재협상 요청/철회(IMK #15) 포털 e2e — 요청 접수 + 철회.
담당자 심사(승인/반려) negodata 백엔드 몫이고, 여기(포털) 공급사가
sessions.custom.renegotiation 요청을 남기고(PENDING) 스스로 철회(CANCELED)하는 절반을 본다:
· 개찰(OPEN_*) 마감 + 본인 마지막 라운드 세션 요청 기록(PENDING) + 담당자 알림
· 낙찰(AWARDED) 요청 거부
· 남의 공급사 세션 거부(FORBIDDEN)
· 이미 대기 중인데 재요청 거부(중복 방지)
· 대기 철회 CANCELED, 이후 재요청 허용
dev negosium_db 그대로 쓰므로(APP_ENV=local) 전용 테스트 행만 시드하고 끝나면 지운다.
"""
import uuid
import bcrypt
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseReason, QuotationStatus, RenegotiationStatus, SessionStatus
TEST_LOGIN_ID = "pytest_renego_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트재협상공급사"
MARK = "PYTESTRENEGO-" # 시드 식별용 prefix (item code / qt number)
@pytest_asyncio.fixture
async def renego_seed(db_engine):
"""공급사 + 로그인유저 + 재협상 후보 세션들을 시드하고 (supplier_id, sids, uids) 반환.
(code, quotation.status, close_reason, 소속 공급사) 요청 자격은 견적 마감사유·소유로 갈린다.
"""
supplier_id = uuid.uuid4()
other_supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
specs = [
("OPEN", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, supplier_id), # 개찰 → 요청 가능
("AWARD", QuotationStatus.CLOSED.value, CloseReason.AWARDED.value, supplier_id), # 낙찰 → 불가
("OTHER", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, other_supplier_id), # 남의 공급사
]
sids, uids = {}, {}
async def _cleanup(conn):
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM company.notifications WHERE ref_qt_id IN "
f"(SELECT qt_id FROM quotation.quotations WHERE number LIKE '{MARK}%')"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn)
await conn.execute(
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) "
"VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text("INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
)
for code, quote_st, close_reason, sup in specs:
item_id, qt_id, session_id, user_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code], uids[code] = session_id, user_id
await conn.execute(
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text("INSERT INTO quotation.quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
" round, start_time, end_time) VALUES "
"(:qid, :uid, gen_random_uuid(), gen_random_uuid(), :name, :num, 2, :st, :cr, "
" 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"),
{"qid": qt_id, "uid": user_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "st": quote_st, "cr": close_reason},
)
await conn.execute(
text("INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, bid_price, end_time) VALUES "
"(:sesid, :qid, :iid, :sup, :qtn, 1, 2, 100000, :sst, 95000, now() - make_interval(hours => 1))"),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "sst": SessionStatus.DONE.value},
)
yield {"supplier_id": supplier_id, "sids": sids, "uids": uids}
async with db_engine.begin() as conn:
await _cleanup(conn)
async def _login_token(client):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
return r.json()["access_token"]
async def _request(client, token, session_id, *, reason="가격 재검토", desired_price=90000):
return await client.post(
f"/v1/negotiation/session/{session_id}/renegotiation",
headers={"Authorization": f"Bearer {token}"},
json={"reason": reason, "desired_price": desired_price},
)
async def _cancel(client, token, session_id):
return await client.delete(
f"/v1/negotiation/session/{session_id}/renegotiation",
headers={"Authorization": f"Bearer {token}"},
)
async def _renego(db_engine, session_id):
async with db_engine.begin() as conn:
row = (await conn.execute(
text("SELECT custom FROM negotiation.sessions WHERE session_id = :sid"),
{"sid": session_id},
)).scalar()
return (row or {}).get("renegotiation") or {}
async def _notif_count(db_engine, qt_number):
async with db_engine.begin() as conn:
return (await conn.execute(
text("SELECT count(*) FROM company.notifications WHERE ref_qt_id IN "
"(SELECT qt_id FROM quotation.quotations WHERE number = :num)"),
{"num": qt_number},
)).scalar()
# ---- 요청 -------------------------------------------------------------------
async def test_request_records_pending(client, renego_seed, db_engine):
"""검증: 개찰(OPEN_PRICE) 마감 + 본인 마지막 라운드 세션에 재협상 요청.
기대결과: success + PENDING 기록(사유·희망가 저장) + 담당자 알림 1."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
body = (await _request(client, token, sid, reason="원자재 인상 반영", desired_price=88000)).json()
assert body["result"]["success"] is True
assert body["status"] == RenegotiationStatus.PENDING.value
saved = await _renego(db_engine, sid)
assert saved["status"] == RenegotiationStatus.PENDING.value
assert saved["reason"] == "원자재 인상 반영"
assert saved["desired_price"] == 88000
assert await _notif_count(db_engine, f"{MARK}OPEN") == 1
async def test_request_twice_blocked(client, renego_seed, db_engine):
"""검증: 이미 대기(PENDING) 요청이 있는 세션에 다시 요청.
기대결과: 2번째는 거부(중복 방지) + 상태는 여전히 PENDING 1."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
first = (await _request(client, token, sid)).json()
second = (await _request(client, token, sid)).json()
assert first["result"]["success"] is True
assert second["result"]["success"] is False
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value
async def test_request_blocked_on_awarded(client, renego_seed, db_engine):
"""검증: 낙찰(AWARDED)로 마감된 건에 재협상 요청.
기대결과: 거부(낙찰 건은 재협상 불가) + custom.renegotiation 미기록."""
token = await _login_token(client)
sid = renego_seed["sids"]["AWARD"]
body = (await _request(client, token, sid)).json()
assert body["result"]["success"] is False
assert await _renego(db_engine, sid) == {}
async def test_request_forbidden_other_supplier(client, renego_seed, db_engine):
"""검증: 다른 공급사 소유 세션에 재협상 요청.
기대결과: 거부 + custom.renegotiation 미기록(소유 가드)."""
token = await _login_token(client)
sid = renego_seed["sids"]["OTHER"]
body = (await _request(client, token, sid)).json()
assert body["result"]["success"] is False
assert await _renego(db_engine, sid) == {}
# ---- 철회 -------------------------------------------------------------------
async def test_cancel_sets_canceled_and_allows_rerequest(client, renego_seed, db_engine):
"""검증: 대기 중 요청을 철회한 뒤 다시 요청.
기대결과: 철회 CANCELED 재요청 다시 PENDING(철회 건은 재요청 허용)."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
await _request(client, token, sid)
cancelled = (await _cancel(client, token, sid)).json()
assert cancelled["result"]["success"] is True
assert cancelled["status"] == RenegotiationStatus.CANCELED.value
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.CANCELED.value
again = (await _request(client, token, sid)).json()
assert again["result"]["success"] is True
assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value
async def test_cancel_requires_pending(client, renego_seed, db_engine):
"""검증: 대기 요청이 없는 세션에 철회 시도.
기대결과: 거부(철회할 대기 요청 없음)."""
token = await _login_token(client)
sid = renego_seed["sids"]["OPEN"]
body = (await _cancel(client, token, sid)).json()
assert body["result"]["success"] is False

View File

@ -0,0 +1,51 @@
"""공급사 관점 협상 결과 파생(SessionResult) 단위 테스트.
목록의 result 코드는 견적 마감상태·마감사유·낙찰자로 파생한다(DDL 무변경). 공급사가 배지로
'내가 낙찰인지 / 결렬이라 재협상 요청 대상인지' 구분한다. 결렬(3) renegotiable 짝을 이룬다.
"""
import uuid
from common.enums import CloseReason, QuotationStatus
from services.negotiation_service import NegotiationService
_R = NegotiationService._to_result
ME = uuid.uuid4()
OTHER = uuid.uuid4()
CLOSED = QuotationStatus.CLOSED.value
def test_result_undecided_before_close():
"""검증: 견적이 아직 마감 전(진행중)이면 결과 미정.
기대결과: 0(미정)."""
assert _R(QuotationStatus.IN_PROGRESS.value, None, None, ME) == 0
def test_result_won_when_winner_is_me():
"""검증: 낙찰(AWARDED) 마감 + 낙찰자가 나.
기대결과: 1(낙찰)."""
assert _R(CLOSED, CloseReason.AWARDED.value, ME, ME) == 1
def test_result_lost_when_winner_is_other():
"""검증: 낙찰 마감이지만 낙찰자가 남.
기대결과: 2(미낙찰)."""
assert _R(CLOSED, CloseReason.AWARDED.value, OTHER, ME) == 2
def test_result_lost_when_awarded_without_winner_id():
"""검증: 낙찰인데 낙찰자 id 가 비어 나와 대조 불가.
기대결과: 2(미낙찰) 낙찰이라 단정 하면 낙찰로 오인시키지 않는다."""
assert _R(CLOSED, CloseReason.AWARDED.value, None, ME) == 2
def test_result_open_is_renegotiable():
"""검증: 개찰(OPEN_*) 4종으로 마감(낙찰자 미정=결렬).
기대결과: 전부 3(결렬) 재협상 요청 대상."""
for cr in (CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT):
assert _R(CLOSED, cr.value, None, ME) == 3, cr
def test_result_none_when_closed_without_reason():
"""검증: 마감됐지만 close_reason 이 아직 없음(경계).
기대결과: 0(미정) 낙찰/결렬 어느 쪽도 아님."""
assert _R(CLOSED, None, None, ME) == 0

View File

@ -73,7 +73,8 @@ export interface SessionBrandingResponse {
export interface SessionField { export interface SessionField {
key: string key: string
label: string label: string
type: 'text' | 'number' | 'boolean' type: 'text' | 'number' | 'boolean' | 'select'
options?: string[] // type='select' 일 때 고를 보기 목록
} }
export interface MeResponse { export interface MeResponse {

View File

@ -2,5 +2,11 @@
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, useSaveExtraInfoMutation } from './negotiation.mutations' export {
useCancelRenegotiationMutation,
useParticipateMutation,
useRejectMutation,
useRequestRenegotiationMutation,
useSaveExtraInfoMutation,
} from './negotiation.mutations'
export * from './negotiation.type' export * from './negotiation.type'

View File

@ -6,6 +6,8 @@ import type {
ParticipateResponse, ParticipateResponse,
RejectRequest, RejectRequest,
RejectResponse, RejectResponse,
RenegotiationRequest,
RenegotiationResponse,
SessionListParams, SessionListParams,
SessionListResponse, SessionListResponse,
} from './negotiation.type' } from './negotiation.type'
@ -34,6 +36,23 @@ export const negotiationApi = {
return res.data return res.data
}, },
/** POST /v1/negotiation/session/{id}/renegotiation — 결렬 건 재협상 요청 */
requestRenegotiation: async (sessionId: string, body: RenegotiationRequest): Promise<RenegotiationResponse> => {
const res = await http.post<RenegotiationResponse>(
`/v1/negotiation/session/${sessionId}/renegotiation`,
body,
)
return res.data
},
/** DELETE /v1/negotiation/session/{id}/renegotiation — 심사 대기 중인 요청 철회 */
cancelRenegotiation: async (sessionId: string): Promise<RenegotiationResponse> => {
const res = await http.delete<RenegotiationResponse>(
`/v1/negotiation/session/${sessionId}/renegotiation`,
)
return res.data
},
/** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */ /** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */
saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise<ExtraInfoResponse> => { saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise<ExtraInfoResponse> => {
const res = await http.post<ExtraInfoResponse>( const res = await http.post<ExtraInfoResponse>(

View File

@ -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 { ExtraInfoRequest, RejectRequest } from './negotiation.type' import type { ExtraInfoRequest, RejectRequest, RenegotiationRequest } from './negotiation.type'
/** /**
* 참여: 성공 . * 참여: 성공 .
@ -44,3 +44,24 @@ export function useSaveExtraInfoMutation() {
}, },
}) })
} }
export function useRequestRenegotiationMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId, request }: { sessionId: string; request: RenegotiationRequest }) =>
negotiationApi.requestRenegotiation(sessionId, request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}
export function useCancelRenegotiationMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId }: { sessionId: string }) => negotiationApi.cancelRenegotiation(sessionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}

View File

@ -43,6 +43,8 @@ export interface SessionListParams {
order?: 'asc' | 'desc' // 생략 시 기본 그룹 정렬('할 일' 우선+임박순, 종료는 하단). 지정 시 그룹 무시하고 전체 마감순(asc=임박/desc=여유) order?: 'asc' | 'desc' // 생략 시 기본 그룹 정렬('할 일' 우선+임박순, 종료는 하단). 지정 시 그룹 무시하고 전체 마감순(asc=임박/desc=여유)
page?: number page?: number
page_size?: number page_size?: number
keyword?: string // 검색어 — 견적번호·상품명·상품코드 부분일치
result?: number // 결과 필터(SessionResult): 1=낙찰 2=미낙찰 3=결렬
} }
export interface SessionListItem { export interface SessionListItem {
@ -56,6 +58,40 @@ export interface SessionListItem {
model_name: string model_name: string
maker_name: string maker_name: string
custom: Record<string, unknown> // 협상완료 부가정보(sessions.custom). 미입력이면 {} custom: Record<string, unknown> // 협상완료 부가정보(sessions.custom). 미입력이면 {}
renegotiable: boolean // 재협상 요청 가능 여부(서버 판정 — 개찰 마감 + 마지막 차수 + 대기 요청 없음)
renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
}
/** 공급사 관점 협상 결과 (sessions 파생) */
export const SessionResult = { NONE: 0, WON: 1, LOST: 2, OPEN: 3 } as const
export const SESSION_RESULT_LABEL: Record<number, string> = {
1: '낙찰',
2: '미낙찰',
3: '결렬',
}
// 재협상 요청(IMK #15)
export interface RenegotiationRequest {
reason: string
desired_price?: number | null
}
export interface RenegotiationResponse {
result: ApiResult
session_id: string
status: number
}
export const RenegoStatus = { NONE: 0, PENDING: 1, APPROVED: 2, REJECTED: 3, CANCELED: 4 } as const
export const RENEGO_STATUS_LABEL: Record<number, string> = {
1: '재협상 심사 중',
2: '재협상 승인됨',
3: '재협상 반려됨',
4: '요청 철회됨',
} }
export interface SessionListResponse { export interface SessionListResponse {

View File

@ -6,7 +6,6 @@ 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'
@ -22,7 +21,7 @@ export function ChatMessage() {
*/} */}
<div <div
ref={scrollRef} ref={scrollRef}
className="chat-scroll h-full overflow-y-auto flex flex-col pt-4 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]" className="chat-scroll h-full overflow-y-auto flex flex-col pt-0 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
> >
<ChatList scrollRef={scrollRef} /> <ChatList scrollRef={scrollRef} />
</div> </div>
@ -48,7 +47,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
scroller.scrollTo({ top: scroller.scrollHeight, behavior }) scroller.scrollTo({ top: scroller.scrollHeight, behavior })
}) })
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
}, [chats, isLoading]) }, [chats, isLoading, scrollRef])
if (!chats || chats.length === 0) { if (!chats || chats.length === 0) {
return ( return (
@ -124,12 +123,7 @@ 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 && ( {message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={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}

View File

@ -0,0 +1,114 @@
import { useState } from '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'
// 타결 요약의 '동의' 단계 하단 액션바 = 부가정보 입력 + 동의를 한 자리에서.
// 다른 입력(가격·배송)과 같은 자리에서 받는다. proceedText = 동의 문구(누르면 저장 후 그 텍스트로 협상 종료).
// 필드 정의(session_fields)가 없으면 폼 없이 동의 버튼만 둔다. 저장 후에도 세션 목록에서 수정할 수 있다.
export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
const sessionId = useChatStore((s) => s.sessionId)
const sendMessage = useChatStore((s) => s.sendMessage)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const save = useSaveExtraInfoMutation()
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
// 사용자가 건드린 값만 state 로 두고, 나머지는 기존값/기본값에서 렌더마다 파생한다(초기화 effect 불필요).
const [overrides, setOverrides] = useState<Record<string, unknown>>({})
const values: Record<string, unknown> = {}
for (const f of fields) values[f.key] = overrides[f.key] ?? existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
const proceed = () => sendMessage(proceedText)
// 필드 미정의 회사 → 부가정보 없이 동의만.
if (fields.length === 0) {
return (
<div className="flex w-full justify-center">
<button className={PRIMARY} onClick={proceed}>
{proceedText || '확인'}
</button>
</div>
)
}
const set = (key: string, value: unknown) => setOverrides((v) => ({ ...v, [key]: value }))
// 저장 성공 후에만 동의(협상 종료)로 넘어간다 — 저장 실패 시 화면 유지.
const handleSaveAndProceed = () => {
if (save.isPending) return
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: () => proceed(),
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return (
<div className="w-full">
<p className="mb-3 text-sm font-semibold text-neutral-90"> .</p>
{/* overflow-y-auto overflow-x auto () focus ring .
p-1 -m-1 ( ) . */}
<div className="max-h-[36vh] space-y-3 overflow-y-auto p-1 -m-1">
{fields.map((f) => (
<div key={f.key} className="flex items-center gap-3">
<label className="w-28 shrink-0 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 shrink-0 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>
) : f.type === 'select' ? (
<select
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
className="h-10 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"
>
<option value=""></option>
{(f.options ?? []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSaveAndProceed()}
className="h-10 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>
<button type="button" onClick={handleSaveAndProceed} disabled={save.isPending} className={`${PRIMARY} mt-3 w-full`}>
{save.isPending ? '저장 중…' : proceedText || '저장하고 마무리'}
</button>
</div>
)
}
const PRIMARY =
'h-[46px] min-w-[120px] rounded-xl bg-brand-600 px-6 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50'

View File

@ -1,7 +1,7 @@
import { Check } from 'lucide-react' import { Check } from 'lucide-react'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { STEPS } from '@/features/chat/components/menu/NegoStep' import { STEPS } from '@/features/chat/lib/negoSteps'
// 모바일 전용: 협상절차를 채팅 상단에 가로 스텝바로 항상 표시한다. (데스크톱은 우측 패널 사용) // 모바일 전용: 협상절차를 채팅 상단에 가로 스텝바로 항상 표시한다. (데스크톱은 우측 패널 사용)
export function MobileStepBar() { export function MobileStepBar() {

View File

@ -20,12 +20,23 @@ export function RemainingTime() {
return ( return (
<span <span
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold whitespace-nowrap tabular-nums', // min-w-0 + truncate: 헤더가 좁아도 잘려나가지 않고 말줄임으로 접힌다.
'inline-flex min-w-0 shrink items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold tabular-nums',
ended ? 'bg-neutral-20 text-neutral-60' : 'bg-brand-light text-brand-600', ended ? 'bg-neutral-20 text-neutral-60' : 'bg-brand-light text-brand-600',
)} )}
> >
<Clock className="size-3.5" /> <Clock className="size-3.5 shrink-0" />
{remaining} <span className="truncate">
{ended ? (
// 종료 시엔 어느 폭에서도 긴 문장('종료되었습니다.')을 쓰지 않는다 — 헤더에서 잘리던 원인.
'마감 종료'
) : (
<>
<span className="hidden sm:inline"> </span>
{remaining}
</>
)}
</span>
</span> </span>
) )
} }

View File

@ -1,7 +1,9 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { Percent, Price } from '@/features/chat/components/userInputs' import { Percent, Price } from '@/features/chat/components/userInputs'
import { ExtraInfoBar } from '@/features/chat/components/ExtraInfoBar'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig' import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import type { UserButtonConfig } from '@/features/chat/types' import type { UserButtonConfig } from '@/features/chat/types'
@ -17,6 +19,15 @@ const style = {
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) { export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
if (type === '') return null if (type === '') return null
// 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구.
if (type === 'extra-info') {
return (
<div className="w-full px-6 py-4 max-[1180px]:px-4">
<ExtraInfoBar proceedText={text || ''} />
</div>
)
}
return ( return (
<div className="flex w-full justify-center overflow-x-auto px-6 py-4 max-[1180px]:px-4"> <div className="flex w-full justify-center overflow-x-auto px-6 py-4 max-[1180px]:px-4">
<div className="flex justify-center"> <div className="flex justify-center">
@ -82,6 +93,18 @@ function ThreeBlack({ textList }: { textList: [string, string, string] }) {
function BlackWhite({ textList }: { textList: [string, string] }) { function BlackWhite({ textList }: { textList: [string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage) const sendMessage = useChatStore((s) => s.sendMessage)
// Enter = 첫 버튼(예). 입력창이 없는 선택 단계라 전역 Enter 를 잡아도 안전하다.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault()
sendMessage(textList[0])
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [sendMessage, textList])
return ( return (
<div className="flex gap-3"> <div className="flex gap-3">
<button className={style.black} onClick={() => sendMessage(textList[0])}> <button className={style.black} onClick={() => sendMessage(textList[0])}>

View File

@ -1,20 +1,13 @@
import { Check, ChevronDown } from 'lucide-react' import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { STEPS } from '@/features/chat/lib/negoSteps'
interface Props { interface Props {
isOpen: boolean isOpen: boolean
setIsOpen: (isOpen: boolean) => void setIsOpen: (isOpen: boolean) => void
} }
export const STEPS = [
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
{ name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' },
{ name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' },
]
export function NegoStep({ isOpen, setIsOpen }: Props) { export function NegoStep({ isOpen, setIsOpen }: Props) {
const chats = useChatStore((s) => s.messages) const chats = useChatStore((s) => s.messages)
const currentStep = chats[chats.length - 1]?.display_step || '서비스안내' const currentStep = chats[chats.length - 1]?.display_step || '서비스안내'

View File

@ -1,109 +0,0 @@
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>
)
}

View File

@ -10,6 +10,7 @@ export function OtherReason({
onChange, onChange,
isError, isError,
disabled, disabled,
onSubmit,
}: { }: {
inputValue: string inputValue: string
setInputValue: (value: string) => void setInputValue: (value: string) => void
@ -18,6 +19,7 @@ export function OtherReason({
onChange: (value: string) => void onChange: (value: string) => void
isError: boolean isError: boolean
disabled?: boolean disabled?: boolean
onSubmit?: () => void
}) { }) {
const isChecked = selectedValue === '기타' const isChecked = selectedValue === '기타'
const ref = useRef<HTMLTextAreaElement>(null) const ref = useRef<HTMLTextAreaElement>(null)
@ -73,6 +75,13 @@ export function OtherReason({
)} )}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
// Enter=제출, Shift+Enter=줄바꿈. 한글 조합 중 Enter 는 무시.
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && onSubmit) {
e.preventDefault()
onSubmit()
}
}}
placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요." placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요."
disabled={isTextareaDisabled} disabled={isTextareaDisabled}
rows={1} rows={1}

View File

@ -79,6 +79,7 @@ export function RejectCM() {
)} )}
value={price ? parseInt(price).toLocaleString() : ''} value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)} onChange={(e) => handlePriceChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()}
placeholder="0" placeholder="0"
disabled={isDisabled} disabled={isDisabled}
/> />

View File

@ -109,6 +109,7 @@ export function RejectRSP() {
)} )}
value={price ? parseInt(price).toLocaleString() : ''} value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)} onChange={(e) => handlePriceChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()}
placeholder="0" placeholder="0"
disabled={isDisabled} disabled={isDisabled}
/> />
@ -147,6 +148,7 @@ export function RejectRSP() {
onChange={handleRadioChange} onChange={handleRadioChange}
isError={!!radioErrorMessage} isError={!!radioErrorMessage}
disabled={isDisabled} disabled={isDisabled}
onSubmit={handleSubmit}
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,8 @@
// 협상 절차 5단계 — NegoStep(사이드) · MobileStepBar(모바일 상단)가 공유.
export const STEPS = [
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
{ name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' },
{ name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' },
]

View File

@ -16,7 +16,12 @@ export function deriveUserButtonConfig(
const options = last.next_input_type const options = last.next_input_type
if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT } if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT }
if (mode === 'confirm') return { type: 'one-black', text: options?.[0] || '' } // 타결 요약이 뜬 뒤의 '동의' 단계 = 부가정보 입력 + 동의를 한 자리(액션바)에서 받는다.
// (요약 이후 confirm 단계에만 적용 — 재견적의 투찰확정/정보수정 같은 선택 단계는 그대로 둔다.)
const dealt = messages.some((m) => m.bot_chat_type === 'summaryRSP' || m.bot_chat_type === 'summaryCM')
if (mode === 'confirm') {
return dealt ? { type: 'extra-info', text: options?.[0] || '' } : { type: 'one-black', text: options?.[0] || '' }
}
if (mode === 'yes_no') return { type: 'black-white', textList: options || [] } if (mode === 'yes_no') return { type: 'black-white', textList: options || [] }
if (mode === 'percent') return { type: 'percent' } if (mode === 'percent') return { type: 'percent' }
if (mode === 'price') return { type: 'price', priceErrorMessage: priceErrorMessage || undefined } if (mode === 'price') return { type: 'price', priceErrorMessage: priceErrorMessage || undefined }

View File

@ -53,6 +53,7 @@ export type UserButtonType =
| 'percent' | 'percent'
| 'three-black' | 'three-black'
| 'price' | 'price'
| 'extra-info'
| 'loading' | 'loading'
| '' | ''

View File

@ -78,6 +78,17 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
}`} }`}
/> />
</button> </button>
) : f.type === 'select' ? (
<select
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"
>
<option value=""></option>
{(f.options ?? []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
) : ( ) : (
<input <input
type={f.type === 'number' ? 'number' : 'text'} type={f.type === 'number' ? 'number' : 'text'}

View File

@ -0,0 +1,92 @@
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { cn, interactive } from '@/lib'
// 공급사 포털 목록 화면 이용안내. 상태(진행)·결과(마감)·재협상 3파트로 최근 추가된 용어를 설명한다.
// 헤더의 '이용안내' 버튼으로 여는 수동형 팝업. X·배경 클릭·ESC 로 닫는다.
export function GuidePopup({ onClose }: { onClose: () => void }) {
return (
<Modal onClose={onClose}>
<div className="m-4 flex max-h-[90vh] w-full max-w-[560px] flex-col overflow-y-auto rounded-2xl border border-border bg-white p-6 shadow-xl animate-scale-in sm:p-8">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-extrabold text-neutral-90"></h2>
<p className="mt-1 text-[13px] text-neutral-60"> ·· .</p>
</div>
<button
type="button"
aria-label="닫기"
onClick={onClose}
className={cn('flex size-9 shrink-0 items-center justify-center rounded-full bg-neutral-10 text-neutral-70 hover:bg-neutral-20', interactive)}
>
<X size={18} />
</button>
</div>
<div className="flex flex-col gap-6">
<Section title="진행 상태" desc="협상 세션이 지금 어느 단계인지 — 내가 무엇을 할지 알려줍니다.">
<Row badge="협상 대기" tone="wait" text="초청받은 협상. 입장해서 가격 협상을 시작하세요." />
<Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." />
<Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." />
<Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." />
<Row badge="협상 거절" tone="reject" text="내가 참여를 거절한 건입니다." />
</Section>
<Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다.">
<Row badge="낙찰" tone="win" text="내가 낙찰되어 계약 대상이 된 건입니다." />
<Row badge="미낙찰" tone="lost" text="다른 공급사가 낙찰되어 이번 계약에서는 제외된 건입니다." />
<Row badge="결렬" tone="open" text="낙찰자 없이 마감된 건입니다. 조건이 바뀌었다면 재협상을 요청할 수 있습니다." />
</Section>
<Section title="재협상 요청" desc="결렬(낙찰자 미정 마감) 건에 한해 가능합니다.">
<p className="text-[13px] leading-relaxed text-neutral-70">
<b className="font-bold text-neutral-90"> </b> · ,
<b className="font-bold text-neutral-90"> </b> .
( ··) .
</p>
</Section>
</div>
<button
type="button"
onClick={onClose}
className="mt-7 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700"
>
</button>
</div>
</Modal>
)
}
function Section({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
return (
<section>
<h3 className="text-sm font-extrabold text-neutral-90">{title}</h3>
<p className="mb-3 mt-0.5 text-xs text-neutral-60">{desc}</p>
<div className="flex flex-col gap-2.5">{children}</div>
</section>
)
}
const TONE: Record<string, string> = {
wait: 'bg-brand-light text-brand-700',
prog: 'bg-[#FFF3E5] text-[#F5A623]',
done: 'bg-[#EAFDF3] text-success',
none: 'bg-neutral-20 text-neutral-60',
reject: 'bg-[#FFEBEB] text-[#FF4D4F]',
win: 'bg-[#EAFDF3] text-success',
lost: 'bg-neutral-20 text-neutral-60',
open: 'bg-[#FFF3E5] text-[#F5A623]',
}
function Row({ badge, tone, text }: { badge: string; tone: string; text: string }) {
return (
<div className="flex items-start gap-2.5">
<span className={cn('mt-0.5 inline-flex shrink-0 items-center rounded-full px-2.5 py-1 text-xs font-bold', TONE[tone])}>
{badge}
</span>
<p className="text-[13px] leading-relaxed text-neutral-70">{text}</p>
</div>
)
}

View File

@ -1,54 +1,85 @@
import { AlertTriangle, CheckCircle2, FileText, RefreshCw } from 'lucide-react' import type { ReactNode } from 'react'
import { cn } from '@/lib' import { cn } from '@/lib'
import { useSessionCounts, type KpiCount } from '@/features/list/hooks/useSessionCounts' import { useSessionCounts, type KpiCount } from '@/features/list/hooks/useSessionCounts'
// key → 아이콘/색 스타일 (콕핏 카드) // 목업(claude artifact) 그대로: 진행(내가 할 일=상태축) / 결과(마감 후=결과축) 두 묶음.
const STYLE: Record<string, { icon: typeof FileText; tile: string; iconColor: string; spin?: boolean }> = { // 모든 카드 동일 크기·높이 — 강조는 색·CTA 로만. 낙찰=초록, 결렬=amber(재협상 가능), 미낙찰=회색.
created: { icon: FileText, tile: 'bg-brand-light', iconColor: 'text-brand-600' },
progress: { icon: RefreshCw, tile: 'bg-sky-50', iconColor: 'text-sky-500', spin: true },
done: { icon: CheckCircle2, tile: 'bg-emerald-50', iconColor: 'text-success' },
rejected: { icon: AlertTriangle, tile: 'bg-red-50', iconColor: 'text-[#FF4D4F]' },
}
interface KpiCardsProps { interface KpiCardsProps {
selectedStatus: string | null selectedStatus: string | null
onSelect: (status: string) => void selectedResult: number | null
onSelectStatus: (status: string) => void
onSelectResult: (code: number) => void
} }
export function KpiCards({ selectedStatus, onSelect }: KpiCardsProps) { // key → 배지 점 색 / 숫자 색 / 카드 배경·테두리 / CTA.
const counts = useSessionCounts() const STYLE: Record<string, { dot?: string; num: string; card?: string; cta?: string; ctaText?: string }> = {
created: { num: 'text-brand-600' },
progress: { num: 'text-neutral-90' },
won: { dot: 'bg-success', num: 'text-success', card: 'border-success/40 bg-gradient-to-b from-emerald-50 to-white', cta: 'text-success', ctaText: '★ 계약 대상' },
open: { dot: 'bg-warning', num: 'text-warning', card: 'border-warning/40', cta: 'text-warning', ctaText: '▲ 재협상 요청 가능' },
lost: { dot: 'bg-neutral-50', num: 'text-neutral-70' },
}
export function KpiCards({ selectedStatus, selectedResult, onSelectStatus, onSelectResult }: KpiCardsProps) {
const { progress, result } = useSessionCounts()
const isActive = (c: KpiCount) =>
c.kind === 'status' ? selectedStatus === c.statusLabel : selectedResult === c.resultCode
const onClick = (c: KpiCount) =>
c.kind === 'status' ? onSelectStatus(c.statusLabel!) : onSelectResult(c.resultCode!)
return ( return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4"> <div className="grid gap-4 lg:grid-cols-[2fr_3fr]">
{counts.map((c) => ( <Cluster label="진행 · 내가 할 일" dot="bg-brand-600" cols="grid-cols-2">
<Card key={c.key} data={c} active={selectedStatus === c.status} onClick={() => onSelect(c.status)} /> {progress.map((c) => (
<Card key={c.key} data={c} active={isActive(c)} onClick={() => onClick(c)} />
))} ))}
</Cluster>
<Cluster label="결과 · 마감 후" dot="bg-success" cols="grid-cols-3">
{result.map((c) => (
<Card key={c.key} data={c} active={isActive(c)} onClick={() => onClick(c)} />
))}
</Cluster>
</div>
)
}
function Cluster({ label, dot, cols, children }: { label: string; dot: string; cols: string; children: ReactNode }) {
return (
<div>
<div className="mb-2 flex items-center gap-1.5 px-0.5 text-[11px] font-extrabold uppercase tracking-wider text-neutral-60">
<span className={cn('size-1.5 rounded-full', dot)} />
{label}
</div>
<div className={cn('grid gap-2.5', cols)}>{children}</div>
</div> </div>
) )
} }
function Card({ data, active, onClick }: { data: KpiCount; active: boolean; onClick: () => void }) { function Card({ data, active, onClick }: { data: KpiCount; active: boolean; onClick: () => void }) {
const s = STYLE[data.key] const s = STYLE[data.key] ?? STYLE.created
const Icon = s.icon const ctaText = s.ctaText ?? ' ' // 빈 카드도 CTA 줄 높이를 확보(nbsp) → 5개 카드 높이 완전 동일
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'flex items-center gap-3.5 rounded-2xl border bg-white p-4 text-left transition-all sm:p-5', 'flex h-full flex-col rounded-2xl border bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md active:scale-[0.99]',
'hover:shadow-md active:scale-[0.99]', s.card ?? 'border-border',
active ? 'border-brand-600 ring-2 ring-brand-600/10 shadow-sm' : 'border-border shadow-sm', // 오프셋 링이라 카드 배경색(낙찰 초록·결렬 amber)과 무관하게 선택이 또렷하게 보인다.
active && 'ring-2 ring-brand-600 ring-offset-2 ring-offset-surface',
)} )}
> >
<div className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl', s.tile)}> <p className="mb-2 flex items-center gap-1.5 whitespace-nowrap text-xs font-bold text-neutral-70">
<Icon className={cn('size-5', s.iconColor, s.spin && 'animate-spin-slow')} /> {s.dot && <span className={cn('size-1.5 rounded-full', s.dot)} />}
</div> {data.label}
<div className="min-w-0"> </p>
<p className="truncate text-[11px] font-bold text-neutral-60">{data.label}</p> <p className={cn('text-2xl font-extrabold tabular-nums', s.num)}>
<p className="text-lg font-extrabold tabular-nums text-neutral-90">
{data.isLoading ? '' : data.count.toLocaleString()} {data.isLoading ? '' : data.count.toLocaleString()}
<span className="ml-0.5 text-xs font-bold text-neutral-60"></span> <span className="ml-0.5 text-xs font-bold text-neutral-60"></span>
</p> </p>
</div> {/* CTA 는 좁은 카드(모바일 3열)에서 nowrap 이면 넘치므로 단어 단위로만 줄바꿈 */}
<p className={cn('mt-1.5 break-keep text-[11px] font-extrabold', s.cta)}>{ctaText}</p>
</button> </button>
) )
} }

View File

@ -0,0 +1,54 @@
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '../types'
export interface RenegotiationMemoPopupProps {
target: ListItem
onClose: () => void
}
// 재협상 심사 결과의 담당자 메모 열람 팝업. 목록에는 상태 배지만 두고 메모 전문은 여기서 보여준다.
export function RenegotiationMemoPopup({ target, onClose }: RenegotiationMemoPopupProps) {
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">
{RENEGO_STATUS_LABEL[target.renegotiationStatus] ?? '재협상 심사 결과'}
</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="p-5">
<p className="text-sm font-semibold text-neutral-80"> </p>
<p className="mt-1.5 max-h-64 overflow-y-auto whitespace-pre-wrap break-keep rounded-xl bg-neutral-10 p-3 text-sm leading-relaxed text-neutral-80">
{target.renegotiationMemo}
</p>
</div>
<div className="border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="w-full rounded-xl bg-brand-600 py-3 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98]"
>
</button>
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,122 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import type { ListItem } from '../types'
export interface RenegotiationPopupProps {
target: ListItem
onClose: () => void
onSubmit: (reason: string, desiredPrice: number | null) => void
}
// 재협상 요청 팝업. 결렬(낙찰자 미정) 건에만 열리며, 담당자 승인 시 다음 차수가 생성된다.
// 사유는 자주 쓰는 것을 프리셋으로 두고, 직접 입력도 허용한다.
const PRESETS = ['가격 조건 재검토', '재고·납기 확보', '단가 정정', '수량 조건 변경'] as const
export function RenegotiationPopup({ target, onClose, onSubmit }: RenegotiationPopupProps) {
const [preset, setPreset] = useState<string>(PRESETS[0])
const [custom, setCustom] = useState('')
const [price, setPrice] = useState('')
const isDirect = preset === '직접 입력'
const reason = isDirect ? custom.trim() : preset
const canSubmit = reason.length > 0
const handleSubmit = () => {
if (!canSubmit) return
const parsed = Number(price.replace(/[^0-9]/g, ''))
onSubmit(reason, parsed > 0 ? parsed : null)
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">
<p className="rounded-xl bg-neutral-10 p-3 text-xs leading-relaxed text-neutral-70">
.
<b> </b> . · .
</p>
<div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80"> </label>
<div className="flex flex-wrap gap-1.5">
{[...PRESETS, '직접 입력'].map((p) => (
<button
key={p}
type="button"
onClick={() => setPreset(p)}
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition-all active:scale-[0.98] ${
preset === p
? 'border-brand-600 bg-brand-50 text-brand-700'
: 'border-border text-neutral-70 hover:bg-neutral-10'
}`}
>
{p}
</button>
))}
</div>
{isDirect && (
<input
value={custom}
onChange={(e) => setCustom(e.target.value)}
maxLength={255}
placeholder="요청 사유를 입력해 주세요"
className="mt-1.5 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"
/>
)}
</div>
<div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">
<span className="font-medium text-neutral-60">()</span>
</label>
<input
inputMode="numeric"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="예: 14,500,000"
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"
/>
<p className="text-xs text-neutral-60"> . .</p>
</div>
</div>
<div className="flex gap-2 border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-xl border border-border py-3 text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="flex-[2] rounded-xl bg-brand-600 py-3 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50"
>
</button>
</div>
</div>
</Modal>
)
}

View File

@ -18,7 +18,7 @@ interface StatusTabsProps {
export function StatusTabs({ selected, onSelect }: StatusTabsProps) { export function StatusTabs({ selected, onSelect }: StatusTabsProps) {
return ( return (
<div className="flex flex-wrap items-center gap-1 rounded-xl bg-neutral-20 p-1"> <div className="flex flex-wrap items-center gap-1">
{TABS.map((t) => { {TABS.map((t) => {
const active = selected === t.value const active = selected === t.value
return ( return (
@ -27,8 +27,8 @@ export function StatusTabs({ selected, onSelect }: StatusTabsProps) {
type="button" type="button"
onClick={() => onSelect(t.value)} onClick={() => onSelect(t.value)}
className={cn( className={cn(
'rounded-lg px-3.5 py-1.5 text-[13px] font-bold transition-all active:scale-[0.98]', 'whitespace-nowrap rounded-[10px] px-3 py-[7px] text-[13px] font-bold transition-all active:scale-[0.98]',
active ? 'bg-white text-neutral-90 shadow-sm' : 'text-neutral-60 hover:text-neutral-80', active ? 'bg-brand-light text-brand-700' : 'text-neutral-60 hover:text-neutral-80',
)} )}
> >
{t.label} {t.label}

View File

@ -1,7 +1,8 @@
import { Loader2 } from 'lucide-react' import { Loader2, MessageSquareText } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib' import { cn, formatKstDateTime } from '@/lib'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
import { statusMeta } from '@/features/list/lib/status' import { statusMeta, RESULT_META } from '@/features/list/lib/status'
interface WorkspaceCardsProps { interface WorkspaceCardsProps {
items: ListItem[] items: ListItem[]
@ -10,10 +11,12 @@ interface WorkspaceCardsProps {
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
} }
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) { export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: 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">
@ -28,7 +31,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on
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} onExtraInfo={onExtraInfo} /> <Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
))} ))}
</div> </div>
) )
@ -40,12 +43,16 @@ function Card({
onEnter, onEnter,
onReject, onReject,
onExtraInfo, onExtraInfo,
onRenegotiate,
onMemo,
}: { }: {
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 onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (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)
@ -53,6 +60,8 @@ function Card({
const isDone = item.session_status === '협상완료' const isDone = item.session_status === '협상완료'
const enterLabel = isDone ? '결과 보기' : '협상 입장' const enterLabel = isDone ? '결과 보기' : '협상 입장'
const hasExtra = item.custom && Object.keys(item.custom).length > 0 const hasExtra = item.custom && Object.keys(item.custom).length > 0
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
return ( return (
<div className="p-4"> <div className="p-4">
@ -67,19 +76,54 @@ function Card({
{item.model_name && <span> · {item.model_name}</span>} {item.model_name && <span> · {item.model_name}</span>}
</p> </p>
</div> </div>
<span className={cn('inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}> <div className="flex shrink-0 items-center gap-1.5">
{RESULT_META[item.result] && (
<span className={cn('inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-bold', RESULT_META[item.result].badge)}>
{RESULT_META[item.result].label}
</span>
)}
<span className={cn('inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
<span className={cn('size-1.5 rounded-full', meta.dot)} /> <span className={cn('size-1.5 rounded-full', meta.dot)} />
{meta.display} {meta.display}
</span> </span>
</div> </div>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-60"> <div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-60">
{item.maker_name && <span> {item.maker_name}</span>} {item.maker_name && <span> {item.maker_name}</span>}
<span> {formatKstDateTime(item.qt_end_time)}</span> <span> {formatKstDateTime(item.qt_end_time)}</span>
</div> </div>
{(canEnter || canReject || isDone) && ( {renegoLabel &&
(item.renegotiationMemo ? (
<button
type="button"
onClick={() => onMemo(item)}
className="mt-2.5 flex w-full items-center justify-between rounded-xl bg-neutral-10 px-3 py-2 transition-all hover:bg-neutral-20 active:scale-[0.99]"
>
<span className="whitespace-nowrap text-xs font-bold text-neutral-80">{renegoLabel}</span>
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] text-neutral-60">
<MessageSquareText className="size-3.5 text-neutral-50" />
</span>
</button>
) : (
<div className="mt-2.5 rounded-xl bg-neutral-10 px-3 py-2">
<p className="text-xs font-bold text-neutral-80">{renegoLabel}</p>
</div>
))}
{(canEnter || canReject || isDone || item.renegotiable) && (
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
{item.renegotiable && (
<button
type="button"
onClick={() => onRenegotiate(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]"
>
</button>
)}
{isDone && ( {isDone && (
<button <button
type="button" type="button"

View File

@ -1,8 +1,9 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2, MessageSquareText } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib' import { cn, formatKstDateTime } from '@/lib'
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
import { statusMeta } from '@/features/list/lib/status' import { statusMeta, RESULT_META } from '@/features/list/lib/status'
interface WorkspaceTableProps { interface WorkspaceTableProps {
items: ListItem[] items: ListItem[]
@ -11,22 +12,26 @@ interface WorkspaceTableProps {
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void
} }
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap' // th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
const HEAD = 'px-5 py-3.5 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, onExtraInfo }: WorkspaceTableProps) { export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: 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={cn(HEAD, 'text-left')}></th>
<th className={HEAD}></th> <th className={cn(HEAD, 'text-left')}></th>
<th className={HEAD}></th> <th className={cn(HEAD, 'text-left')}></th>
<th className={cn(HEAD, 'text-center')}></th> <th className={cn(HEAD, 'text-right')}></th>
<th className={HEAD}></th> <th className={cn(HEAD, 'text-center')}></th>
<th className={cn(HEAD, 'text-left')}></th>
<th className={cn(HEAD, 'text-right')}></th> <th className={cn(HEAD, 'text-right')}></th>
</tr> </tr>
</thead> </thead>
@ -41,7 +46,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, on
</StateRow> </StateRow>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} /> <Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
)) ))
)} )}
</tbody> </tbody>
@ -56,12 +61,16 @@ function Row({
onEnter, onEnter,
onReject, onReject,
onExtraInfo, onExtraInfo,
onRenegotiate,
onMemo,
}: { }: {
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 onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void
onMemo: (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)
@ -83,15 +92,50 @@ function Row({
</p> </p>
</td> </td>
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{item.maker_name || '-'}</td> <td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{item.maker_name || '-'}</td>
<td className={cn(CELL, 'text-center')}> <td className={cn(CELL, 'text-right')}>
<span className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}> <span className={cn('inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
<span className={cn('size-1.5 rounded-full', meta.dot)} /> <span className={cn('size-1.5 rounded-full', meta.dot)} />
{meta.display} {meta.display}
</span> </span>
</td> </td>
<td className={cn(CELL, 'text-center')}>
{RESULT_META[item.result] ? (
<span className={cn('inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-bold', RESULT_META[item.result].badge)}>
{RESULT_META[item.result].label}
</span>
) : (
<span className="text-xs text-neutral-40"></span>
)}
</td>
<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">
{/* 재협상: 요청 가능하면 버튼, 요청 이력이 있으면 상태 텍스트(담당자 메모 있으면 클릭 시 팝업) */}
{item.renegotiable ? (
<button
type="button"
onClick={() => onRenegotiate(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]"
>
</button>
) : (
RENEGO_STATUS_LABEL[item.renegotiationStatus] &&
(item.renegotiationMemo ? (
<button
type="button"
onClick={() => onMemo(item)}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg bg-neutral-10 px-2.5 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-20 active:scale-[0.98]"
>
{RENEGO_STATUS_LABEL[item.renegotiationStatus]}
<MessageSquareText className="size-3.5 text-neutral-50" />
</button>
) : (
<span className="whitespace-nowrap rounded-lg bg-neutral-10 px-2.5 py-1.5 text-xs font-bold text-neutral-70">
{RENEGO_STATUS_LABEL[item.renegotiationStatus]}
</span>
))
)}
{isDone && ( {isDone && (
<button <button
type="button" type="button"
@ -120,9 +164,7 @@ function Row({
{busy && <Loader2 className="size-3 animate-spin" />} {busy && <Loader2 className="size-3 animate-spin" />}
{enterLabel} {enterLabel}
</button> </button>
) : ( ) : null}
!canReject && <span className="text-xs text-neutral-50"></span>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -132,7 +174,7 @@ function Row({
function StateRow({ children }: { children: ReactNode }) { function StateRow({ children }: { children: ReactNode }) {
return ( return (
<tr> <tr>
<td colSpan={6} className="py-20"> <td colSpan={7} 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>

View File

@ -1,8 +1,15 @@
import { useState } from 'react' import { useEffect, 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 { HelpCircle, Search } from 'lucide-react'
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from '@/apis' import {
getApiErrorMessage,
useMeQuery,
useParticipateMutation,
useRejectMutation,
useRequestRenegotiationMutation,
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'
@ -13,6 +20,9 @@ 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 { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
import { GuidePopup } from '@/features/list/components/GuidePopup'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
// 상태별 거부 불가 안내 // 상태별 거부 불가 안내
@ -30,18 +40,30 @@ export function ListWorkspace() {
const participate = useParticipateMutation() const participate = useParticipateMutation()
const reject = useRejectMutation() const reject = useRejectMutation()
const saveExtra = useSaveExtraInfoMutation() const saveExtra = useSaveExtraInfoMutation()
const requestRenego = useRequestRenegotiationMutation()
const { selectedStatus, setStatus } = useListStore( const { selectedStatus, selectedResult, setStatus, toggleResult, setKeyword } = useListStore(
useShallow((s) => ({ useShallow((s) => ({
selectedStatus: s.selectedStatus, selectedStatus: s.selectedStatus,
selectedResult: s.selectedResult,
setStatus: s.setStatus, setStatus: s.setStatus,
toggleResult: s.toggleResult,
setKeyword: s.setKeyword,
})), })),
) )
// 입력은 즉시 반영하되, 서버 요청은 300ms 디바운스(타이핑마다 호출 방지). 변경 시 store 가 페이지를 1로 되돌린다.
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
useEffect(() => {
const id = setTimeout(() => setKeyword(search), 300)
return () => clearTimeout(id)
}, [search, setKeyword])
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 [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const handleEnter = (item: ListItem) => { const handleEnter = (item: ListItem) => {
setEnteringId(item.session_id) setEnteringId(item.session_id)
@ -85,22 +107,46 @@ export function ListWorkspace() {
) )
} }
const handleRenegoSubmit = (reason: string, desiredPrice: number | null) => {
if (!renegoTarget) return
requestRenego.mutate(
{ sessionId: renegoTarget.session_id, request: { reason, desired_price: desiredPrice } },
{
onSuccess: () => toast.success('재협상을 요청했습니다. 담당자 검토 후 결과가 목록에 표시됩니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '재협상 요청에 실패했습니다.')),
},
)
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* 환영 배너 */} {/* 헤더 */}
<div className="flex flex-wrap items-center justify-between gap-2 rounded-2xl border border-border bg-white px-5 py-4 shadow-[0_2px_12px_rgba(0,0,0,0.02)]"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div> <div>
<p className="text-[15px] font-bold text-neutral-90"> <h1 className="text-xl font-extrabold tracking-tight text-neutral-90"> </h1>
{user?.supplierName ? `${user.supplierName} 담당자님, 환영합니다.` : '공급 파트너님, 환영합니다.'} <p className="mt-0.5 text-[13px] text-neutral-60">
</p> {user?.supplierName ? `${user.supplierName} (공급 파트너)` : '공급 파트너'} · /
<p className="mt-0.5 text-xs text-neutral-60">
.
</p> </p>
</div> </div>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={() => setGuideOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-white px-3 py-1.5 text-[13px] font-bold text-neutral-70 transition-colors hover:bg-neutral-10"
>
<HelpCircle className="size-4 text-neutral-50" />
</button>
</div>
</div> </div>
{/* KPI 콕핏 */} {/* KPI 콕핏 — 진행/결과 두 묶음 */}
<KpiCards selectedStatus={selectedStatus} onSelect={(s) => setStatus(selectedStatus === s ? null : s)} /> <KpiCards
selectedStatus={selectedStatus}
selectedResult={selectedResult}
onSelectStatus={(s) => setStatus(selectedStatus === s ? null : s)}
onSelectResult={toggleResult}
/>
{/* 워크스페이스 카드 */} {/* 워크스페이스 카드 */}
<div className="overflow-hidden rounded-2xl border border-border bg-white shadow-sm"> <div className="overflow-hidden rounded-2xl border border-border bg-white shadow-sm">
@ -131,6 +177,8 @@ export function ListWorkspace() {
onEnter={handleEnter} onEnter={handleEnter}
onReject={handleReject} onReject={handleReject}
onExtraInfo={setExtraTarget} onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget}
/> />
</div> </div>
<div className="lg:hidden"> <div className="lg:hidden">
@ -141,6 +189,8 @@ export function ListWorkspace() {
onEnter={handleEnter} onEnter={handleEnter}
onReject={handleReject} onReject={handleReject}
onExtraInfo={setExtraTarget} onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget}
/> />
</div> </div>
@ -149,6 +199,8 @@ export function ListWorkspace() {
</div> </div>
</div> </div>
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
{rejectTarget && ( {rejectTarget && (
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} /> <RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
)} )}
@ -156,6 +208,18 @@ export function ListWorkspace() {
{extraTarget && ( {extraTarget && (
<ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} /> <ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} />
)} )}
{renegoTarget && (
<RenegotiationPopup
target={renegoTarget}
onClose={() => setRenegoTarget(null)}
onSubmit={handleRenegoSubmit}
/>
)}
{memoTarget && (
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
)}
</div> </div>
) )
} }

View File

@ -7,11 +7,13 @@ const PAGE_SIZE = 20
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다. // 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
export function useList() { export function useList() {
const { selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore() const { selectedStatus, selectedDeadline, selectedResult, keyword, currentPage, setCurrentPage } = useListStore()
const query = useSessionListQuery({ const query = useSessionListQuery({
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined, status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined, order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
result: selectedResult ?? undefined,
keyword: keyword.trim() || undefined,
page: currentPage, page: currentPage,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}) })

View File

@ -1,35 +1,63 @@
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { negotiationApi, negotiationKeys, SessionStatus } from '@/apis' import { negotiationApi, negotiationKeys, SessionStatus, SessionResult } from '@/apis'
// KPI 콕핏에 노출할 상태별 집계. 각 상태를 page_size=1 로 조회해 total 만 읽는다(목록과 캐시 공유). // KPI 콕핏 집계. 진행(상태축)·결과(결과축) 두 묶음. 각 항목을 page_size=1 로 조회해 total 만 읽는다(목록과 캐시 공유).
// 클릭 시 걸 필터가 상태냐 결과냐가 다르므로 kind 로 구분해 컨테이너가 올바른 setter 로 보낸다.
export interface KpiCount { export interface KpiCount {
key: string key: string
label: string label: string
status: string // 클릭 시 필터에 넣을 원본 상태 라벨 kind: 'status' | 'result'
code: number statusLabel?: string // kind=status: 필터에 넣을 원본 상태 라벨
resultCode?: number // kind=result: 필터에 넣을 SessionResult 코드
count: number count: number
isLoading: boolean isLoading: boolean
} }
const KPI = [ // 진행 묶음(내가 할 일) — 세션 상태축.
{ key: 'created', label: '협상 대기', status: '협상생성', code: SessionStatus.CREATED }, const PROGRESS = [
{ key: 'progress', label: '조율 진행중', status: '협상중', code: SessionStatus.IN_PROGRESS }, { key: 'created', label: '협상 대기', statusLabel: '협상생성', code: SessionStatus.CREATED },
{ key: 'done', label: '타결 완료', status: '협상완료', code: SessionStatus.DONE }, { key: 'progress', label: '협상 중', statusLabel: '협상중', code: SessionStatus.IN_PROGRESS },
{ key: 'rejected', label: '거부/반려', status: '협상거부', code: SessionStatus.REJECTED },
] as const ] as const
export function useSessionCounts(): KpiCount[] { // 결과 묶음(마감 후) — 결과축. 낙찰=주강조, 결렬=재협상 가능(액션).
const results = useQueries({ const RESULT = [
queries: KPI.map((k) => ({ { key: 'won', label: '낙찰', code: SessionResult.WON },
{ key: 'open', label: '결렬', code: SessionResult.OPEN },
{ key: 'lost', label: '미낙찰', code: SessionResult.LOST },
] as const
export function useSessionCounts(): { progress: KpiCount[]; result: KpiCount[] } {
const statusQ = useQueries({
queries: PROGRESS.map((k) => ({
queryKey: negotiationKeys.sessionList({ status: k.code, page: 1, page_size: 1 }), queryKey: negotiationKeys.sessionList({ status: k.code, page: 1, page_size: 1 }),
queryFn: () => negotiationApi.getSessions({ status: k.code, page: 1, page_size: 1 }), queryFn: () => negotiationApi.getSessions({ status: k.code, page: 1, page_size: 1 }),
staleTime: 60 * 1000, staleTime: 60 * 1000,
})), })),
}) })
const resultQ = useQueries({
queries: RESULT.map((k) => ({
queryKey: negotiationKeys.sessionList({ result: k.code, page: 1, page_size: 1 }),
queryFn: () => negotiationApi.getSessions({ result: k.code, page: 1, page_size: 1 }),
staleTime: 60 * 1000,
})),
})
return KPI.map((k, i) => ({ return {
...k, progress: PROGRESS.map((k, i) => ({
count: results[i].data?.total ?? 0, key: k.key,
isLoading: results[i].isLoading, label: k.label,
})) kind: 'status',
statusLabel: k.statusLabel,
count: statusQ[i].data?.total ?? 0,
isLoading: statusQ[i].isLoading,
})),
result: RESULT.map((k, i) => ({
key: k.key,
label: k.label,
kind: 'result',
resultCode: k.code,
count: resultQ[i].data?.total ?? 0,
isLoading: resultQ[i].isLoading,
})),
}
} }

View File

@ -38,5 +38,9 @@ export function toListItem(api: SessionListItem): ListItem {
model_name: api.model_name, model_name: api.model_name,
maker_name: api.maker_name, maker_name: api.maker_name,
custom: api.custom ?? {}, custom: api.custom ?? {},
renegotiable: api.renegotiable ?? false,
renegotiationStatus: api.renegotiation_status ?? 0,
renegotiationMemo: api.renegotiation_memo ?? '',
result: api.result ?? 0,
} }
} }

View File

@ -7,13 +7,21 @@ export interface StatusMeta {
} }
export const STATUS_META: Record<string, StatusMeta> = { export const STATUS_META: Record<string, StatusMeta> = {
: { display: '대기', badge: 'bg-brand-light text-brand-600', dot: 'bg-brand-600' }, : { display: '협상 대기', badge: 'bg-brand-light text-brand-600', dot: 'bg-brand-600' },
: { display: '조율중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' }, : { display: '협상 중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' },
: { display: '타결', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' }, : { display: '협상 완료', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' },
: { display: '미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }, : { display: '협상 미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' },
: { display: '거부', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' }, : { display: '협상 거절', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
} }
export function statusMeta(label: string): StatusMeta { export function statusMeta(label: string): StatusMeta {
return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' } return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }
} }
// 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다.
// 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다.
export const RESULT_META: Record<number, { label: string; badge: string }> = {
1: { label: '낙찰', badge: 'bg-[#EAFDF3] text-success' },
2: { label: '미낙찰', badge: 'bg-neutral-20 text-neutral-60' },
3: { label: '결렬', badge: 'bg-[#FFF3E5] text-[#F5A623]' },
}

View File

@ -4,6 +4,8 @@ interface ListState {
selectedType: string | null selectedType: string | null
selectedStatus: string | null selectedStatus: string | null
selectedDeadline: string | null selectedDeadline: string | null
selectedResult: number | null // 결과 필터(SessionResult 코드). 상태와 별개 축이라 함께 걸린다
keyword: string
currentPage: number currentPage: number
toggleType: (item: string) => void toggleType: (item: string) => void
@ -11,6 +13,8 @@ interface ListState {
toggleStatus: (item: string) => void toggleStatus: (item: string) => void
setStatus: (item: string | null) => void setStatus: (item: string | null) => void
toggleDeadline: (item: string) => void toggleDeadline: (item: string) => void
toggleResult: (code: number) => void
setKeyword: (keyword: string) => void
setCurrentPage: (page: number) => void setCurrentPage: (page: number) => void
resetFilters: () => void resetFilters: () => void
} }
@ -21,17 +25,23 @@ export const useListStore = create<ListState>((set) => ({
selectedType: null, selectedType: null,
selectedStatus: null, selectedStatus: null,
selectedDeadline: null, selectedDeadline: null,
selectedResult: null,
keyword: '',
currentPage: 1, currentPage: 1,
toggleType: (item) => toggleType: (item) =>
set((s) => ({ selectedType: toggle(s.selectedType, item), currentPage: 1 })), set((s) => ({ selectedType: toggle(s.selectedType, item), currentPage: 1 })),
setType: (item) => set({ selectedType: item, currentPage: 1 }), setType: (item) => set({ selectedType: item, currentPage: 1 }),
// 상태(진행)와 결과는 마감 전/후라 서로 배타적 — 한 축을 고르면 다른 축은 해제한다(동시 선택=거의 0건).
toggleStatus: (item) => toggleStatus: (item) =>
set((s) => ({ selectedStatus: toggle(s.selectedStatus, item), currentPage: 1 })), set((s) => ({ selectedStatus: toggle(s.selectedStatus, item), selectedResult: null, currentPage: 1 })),
setStatus: (item) => set({ selectedStatus: item, currentPage: 1 }), setStatus: (item) => set({ selectedStatus: item, selectedResult: null, currentPage: 1 }),
toggleDeadline: (item) => toggleDeadline: (item) =>
set((s) => ({ selectedDeadline: toggle(s.selectedDeadline, item), currentPage: 1 })), set((s) => ({ selectedDeadline: toggle(s.selectedDeadline, item), currentPage: 1 })),
toggleResult: (code) =>
set((s) => ({ selectedResult: s.selectedResult === code ? null : code, selectedStatus: null, currentPage: 1 })),
setKeyword: (keyword) => set({ keyword, currentPage: 1 }),
setCurrentPage: (page) => set({ currentPage: page }), setCurrentPage: (page) => set({ currentPage: page }),
resetFilters: () => resetFilters: () =>
set({ selectedType: null, selectedStatus: null, selectedDeadline: null, currentPage: 1 }), set({ selectedType: null, selectedStatus: null, selectedDeadline: null, selectedResult: null, keyword: '', currentPage: 1 }),
})) }))

View File

@ -9,4 +9,8 @@ export type ListItem = {
model_name: string model_name: string
maker_name: string maker_name: string
custom: Record<string, unknown> // 협상완료 부가정보(입력값). 미입력이면 {} custom: Record<string, unknown> // 협상완료 부가정보(입력값). 미입력이면 {}
renegotiable: boolean // 재협상 요청 가능(서버 판정)
renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
} }

View File

@ -160,6 +160,17 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-synthesis: none; font-synthesis: none;
} }
/* Tailwind v4 는 버튼 기본 커서가 default — 클릭/선택 가능한 요소는 포인터로 통일 */
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
input[type='checkbox']:not(:disabled),
input[type='radio']:not(:disabled),
select:not(:disabled),
label[for],
summary {
cursor: pointer;
}
} }
/* 노치/ 인디케이터 회피 (viewport-fit=cover ). 안전영역이 0 기기에선 아무 영향 없다. /* 노치/ 인디케이터 회피 (viewport-fit=cover ). 안전영역이 0 기기에선 아무 영향 없다.

View File

@ -1,9 +1,11 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { cn } from '@/lib' import { cn } from '@/lib'
// safe-t: 노치 기기에서 헤더가 상태바 밑으로 파고들지 않도록 안전영역만큼 키운다(h-[56px] 은 콘텐츠 높이). // safe-t: 노치 기기에서 헤더가 상태바 밑으로 파고들지 않도록 안전영역만큼 키운다.
// box-content 금지 — w-full + px-* 와 겹치면 폭이 "부모100% + 좌우패딩"이 되어 오른쪽이 잘린다.
// 대신 border-box(기본) 유지하고 min-h 로 56px + 안전영역을 확보한다.
const HEADER_BASE = const HEADER_BASE =
'flex w-full h-[56px] box-content safe-t items-center bg-white text-foreground border-b border-border' 'flex w-full min-h-[calc(56px+env(safe-area-inset-top))] safe-t items-center bg-white text-foreground border-b border-border'
const HEADER_ALIGN = { const HEADER_ALIGN = {
left: 'justify-start', left: 'justify-start',

View File

@ -1,5 +1,5 @@
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { Bell, LogOut } from 'lucide-react' import { LogOut } from 'lucide-react'
import { useLogoutMutation, useMeQuery } from '@/apis' import { useLogoutMutation, useMeQuery } from '@/apis'
import { Logo } from '@/components' import { Logo } from '@/components'
import { cn, interactive } from '@/lib' import { cn, interactive } from '@/lib'
@ -18,6 +18,7 @@ export function PortalHeader() {
<Logo size="md" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} /> <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">
{/* (notifications ) .
<button <button
type="button" type="button"
aria-label="알림" aria-label="알림"
@ -29,6 +30,7 @@ export function PortalHeader() {
<Bell className="size-5" /> <Bell className="size-5" />
<span className="absolute right-2 top-2 size-1.5 rounded-full bg-brand-600" /> <span className="absolute right-2 top-2 size-1.5 rounded-full bg-brand-600" />
</button> </button>
*/}
<div className="hidden min-w-0 flex-col items-end sm:flex"> <div className="hidden min-w-0 flex-col items-end sm:flex">
<span className="max-w-[180px] truncate text-[13px] font-bold text-neutral-90"> <span className="max-w-[180px] truncate text-[13px] font-bold text-neutral-90">

View File

@ -25,8 +25,9 @@ export function ChatPage() {
sidebar={<Sidebar />} sidebar={<Sidebar />}
header={ header={
<MainHeaderBar className="px-4 sm:px-6 min-[1180px]:px-8"> <MainHeaderBar className="px-4 sm:px-6 min-[1180px]:px-8">
<div className="flex w-full items-center justify-between gap-2"> {/* min-w-0: flex 기본 min-width:auto 라 자식이 안 줄어들어 헤더 밖으로 넘치던 것 방지 */}
<div className="flex min-w-0 items-center gap-2"> <div className="flex w-full min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 shrink items-center gap-2">
<BackToListButton /> <BackToListButton />
<span className="hidden h-4 w-px bg-border lg:inline-block" /> <span className="hidden h-4 w-px bg-border lg:inline-block" />
<span className="hidden size-2 shrink-0 rounded-full bg-brand-600 animate-pulse lg:inline-block" /> <span className="hidden size-2 shrink-0 rounded-full bg-brand-600 animate-pulse lg:inline-block" />
@ -34,7 +35,7 @@ export function ChatPage() {
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex min-w-0 shrink items-center gap-1.5">
<PanelToggle icon={<Package className="size-4" />} label="상품" panel="product" /> <PanelToggle icon={<Package className="size-4" />} label="상품" panel="product" />
<PanelToggle icon={<ClipboardList className="size-4" />} label="현황" panel="status" /> <PanelToggle icon={<ClipboardList className="size-4" />} label="현황" panel="status" />
<RemainingTime /> <RemainingTime />
@ -92,7 +93,7 @@ function PanelToggle({
type="button" type="button"
onClick={() => open(panel)} onClick={() => open(panel)}
className={cn( className={cn(
'flex items-center gap-1 rounded-lg border border-border px-2 py-1.5 text-xs font-bold text-neutral-70 hover:bg-neutral-10 lg:hidden', 'flex shrink-0 items-center gap-1 rounded-lg border border-border px-2 py-1.5 text-xs font-bold text-neutral-70 hover:bg-neutral-10 lg:hidden',
interactive, interactive,
)} )}
> >