[feat] 공급사 포털: 협상완료 부가정보(select·종료 동의폼)·상태 라벨 통일·예아니오 Enter=예·목록 결과열·재협상 요청/철회 API·테스트
This commit is contained in:
parent
cfee6e89d0
commit
d7c4e86f30
@ -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
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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=철회")
|
||||||
|
|||||||
@ -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)
|
||||||
|
)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
215
backend/tests/test_renegotiation.py
Normal file
215
backend/tests/test_renegotiation.py
Normal 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
|
||||||
51
backend/tests/test_session_result.py
Normal file
51
backend/tests/test_session_result.py
Normal 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
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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>(
|
||||||
|
|||||||
@ -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() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
114
frontend/src/features/chat/components/ExtraInfoBar.tsx
Normal file
114
frontend/src/features/chat/components/ExtraInfoBar.tsx
Normal 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'
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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])}>
|
||||||
|
|||||||
@ -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 || '서비스안내'
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
8
frontend/src/features/chat/lib/negoSteps.ts
Normal file
8
frontend/src/features/chat/lib/negoSteps.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// 협상 절차 5단계 — NegoStep(사이드) · MobileStepBar(모바일 상단)가 공유.
|
||||||
|
export const STEPS = [
|
||||||
|
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
|
||||||
|
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
|
||||||
|
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
|
||||||
|
{ name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' },
|
||||||
|
{ name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' },
|
||||||
|
]
|
||||||
@ -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 }
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export type UserButtonType =
|
|||||||
| 'percent'
|
| 'percent'
|
||||||
| 'three-black'
|
| 'three-black'
|
||||||
| 'price'
|
| 'price'
|
||||||
|
| 'extra-info'
|
||||||
| 'loading'
|
| 'loading'
|
||||||
| ''
|
| ''
|
||||||
|
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
92
frontend/src/features/list/components/GuidePopup.tsx
Normal file
92
frontend/src/features/list/components/GuidePopup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
{/* CTA 는 좁은 카드(모바일 3열)에서 nowrap 이면 넘치므로 단어 단위로만 줄바꿈 */}
|
||||||
</div>
|
<p className={cn('mt-1.5 break-keep text-[11px] font-extrabold', s.cta)}>{ctaText}</p>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
frontend/src/features/list/components/RenegotiationPopup.tsx
Normal file
122
frontend/src/features/list/components/RenegotiationPopup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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,10 +76,17 @@ 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">
|
||||||
<span className={cn('size-1.5 rounded-full', meta.dot)} />
|
{RESULT_META[item.result] && (
|
||||||
{meta.display}
|
<span className={cn('inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-bold', RESULT_META[item.result].badge)}>
|
||||||
</span>
|
{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)} />
|
||||||
|
{meta.display}
|
||||||
|
</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">
|
||||||
@ -78,8 +94,36 @@ function Card({
|
|||||||
<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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]' },
|
||||||
|
}
|
||||||
|
|||||||
@ -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 }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -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=결렬(개찰)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 인 기기에선 아무 영향 없다.
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user