Merge branch 'feature/negodata'
# Conflicts: # landing/app/routes/home.tsx
This commit is contained in:
commit
d580e4d5ea
2
.gitignore
vendored
2
.gitignore
vendored
@ -28,3 +28,5 @@ CLAUDE.md
|
|||||||
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
|
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
|
||||||
/Temp.md
|
/Temp.md
|
||||||
/new.md
|
/new.md
|
||||||
|
|
||||||
|
/mobile.mov
|
||||||
|
|||||||
@ -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'
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
</div>
|
{/* CTA 는 좁은 카드(모바일 3열)에서 nowrap 이면 넘치므로 단어 단위로만 줄바꿈 */}
|
||||||
|
<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,19 +76,54 @@ function Card({
|
|||||||
{item.model_name && <span> · {item.model_name}</span>}
|
{item.model_name && <span> · {item.model_name}</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn('inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{RESULT_META[item.result] && (
|
||||||
|
<span className={cn('inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-bold', RESULT_META[item.result].badge)}>
|
||||||
|
{RESULT_META[item.result].label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
|
||||||
<span className={cn('size-1.5 rounded-full', meta.dot)} />
|
<span className={cn('size-1.5 rounded-full', meta.dot)} />
|
||||||
{meta.display}
|
{meta.display}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-60">
|
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-60">
|
||||||
{item.maker_name && <span>제조사 {item.maker_name}</span>}
|
{item.maker_name && <span>제조사 {item.maker_name}</span>}
|
||||||
<span>마감 {formatKstDateTime(item.qt_end_time)}</span>
|
<span>마감 {formatKstDateTime(item.qt_end_time)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(canEnter || canReject || isDone) && (
|
{renegoLabel &&
|
||||||
|
(item.renegotiationMemo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMemo(item)}
|
||||||
|
className="mt-2.5 flex w-full items-center justify-between rounded-xl bg-neutral-10 px-3 py-2 transition-all hover:bg-neutral-20 active:scale-[0.99]"
|
||||||
|
>
|
||||||
|
<span className="whitespace-nowrap text-xs font-bold text-neutral-80">{renegoLabel}</span>
|
||||||
|
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] text-neutral-60">
|
||||||
|
담당자 메모
|
||||||
|
<MessageSquareText className="size-3.5 text-neutral-50" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2.5 rounded-xl bg-neutral-10 px-3 py-2">
|
||||||
|
<p className="text-xs font-bold text-neutral-80">{renegoLabel}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(canEnter || canReject || isDone || item.renegotiable) && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
|
{item.renegotiable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRenegotiate(item)}
|
||||||
|
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
재협상 요청
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isDone && (
|
{isDone && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -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,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">
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { Comparison } from "@/components/sections/comparison";
|
import { Comparison } from "@/components/sections/comparison";
|
||||||
import { Contact } from "@/components/sections/contact";
|
import { Contact } from "@/components/sections/contact";
|
||||||
import { CoreValues } from "@/components/sections/core-values";
|
import { CoreValues } from "@/components/sections/core-values";
|
||||||
@ -7,8 +5,6 @@ import { Faq } from "@/components/sections/faq";
|
|||||||
import { FinalCTA } from "@/components/sections/final-cta";
|
import { FinalCTA } from "@/components/sections/final-cta";
|
||||||
import { Footer } from "@/components/sections/footer";
|
import { Footer } from "@/components/sections/footer";
|
||||||
import { Header } from "@/components/sections/header";
|
import { Header } from "@/components/sections/header";
|
||||||
import { HeroConsole } from "@/components/sections/hero-console";
|
|
||||||
import { HeroGlassmorphic } from "@/components/sections/hero-glassmorphic";
|
|
||||||
import { HeroNeumorphic } from "@/components/sections/hero-neumorphic";
|
import { HeroNeumorphic } from "@/components/sections/hero-neumorphic";
|
||||||
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
|
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
|
||||||
import { NegotiationConsole } from "@/components/sections/negotiation-console";
|
import { NegotiationConsole } from "@/components/sections/negotiation-console";
|
||||||
@ -30,23 +26,11 @@ export function meta() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeroTheme = "neumorphic" | "glassmorphic" | "console";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// 히어로 디자인 비교용 — 스위처는 dev 에서만 노출, 배포본은 console(Live Console) 고정
|
|
||||||
const [heroTheme, setHeroTheme] = useState<HeroTheme>("neumorphic");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
|
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
|
||||||
<Header />
|
<Header />
|
||||||
|
<HeroNeumorphic />
|
||||||
<div className="relative">
|
|
||||||
{import.meta.env.DEV && <HeroThemeSwitcher value={heroTheme} onChange={setHeroTheme} />}
|
|
||||||
{heroTheme === "neumorphic" && <HeroNeumorphic />}
|
|
||||||
{heroTheme === "glassmorphic" && <HeroGlassmorphic />}
|
|
||||||
{heroTheme === "console" && <HeroConsole />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NegotiationConsole />
|
<NegotiationConsole />
|
||||||
<HowItWorksDemo />
|
<HowItWorksDemo />
|
||||||
<Reinforcement />
|
<Reinforcement />
|
||||||
@ -59,28 +43,3 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const HERO_OPTIONS: { value: HeroTheme; label: string }[] = [
|
|
||||||
{ value: "neumorphic", label: "Phone Demo" },
|
|
||||||
{ value: "glassmorphic", label: "Soft Ambient" },
|
|
||||||
{ value: "console", label: "Live Console" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function HeroThemeSwitcher({ value, onChange }: { value: HeroTheme; onChange: (theme: HeroTheme) => void }) {
|
|
||||||
return (
|
|
||||||
<div className="absolute top-24 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 p-1 rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.06)] border border-line-strong bg-white/95 text-ink-soft backdrop-blur-md text-xs font-semibold">
|
|
||||||
<span className="pl-3.5 pr-1.5 text-[10px] uppercase tracking-wider text-ink-muted font-bold">Hero Design:</span>
|
|
||||||
{HERO_OPTIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
className={`px-3.5 py-1.5 rounded-full transition-all cursor-pointer ${
|
|
||||||
value === option.value ? "bg-primary text-white shadow-sm font-bold" : "hover:text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -229,6 +229,21 @@ class NotificationType(CodeEnum):
|
|||||||
REGENERATED = 2 # 다음 라운드 자동 생성(동가/미참여) — KTC 대응어 없어 negodata 유지
|
REGENERATED = 2 # 다음 라운드 자동 생성(동가/미참여) — KTC 대응어 없어 negodata 유지
|
||||||
FAILURE = 3 # 결렬: 낙찰 없이 마감(거절/부분/한도) — KTC FAILURE
|
FAILURE = 3 # 결렬: 낙찰 없이 마감(거절/부분/한도) — KTC FAILURE
|
||||||
CREATED = 4 # 견적 생성됨(작성 직후) — 생성 알림
|
CREATED = 4 # 견적 생성됨(작성 직후) — 생성 알림
|
||||||
|
RENEGO_REQUESTED = 5 # 공급사가 재협상 요청(IMK #15) — 담당자가 승인/반려할 때까지 배너로 상시 노출
|
||||||
|
|
||||||
|
|
||||||
|
# 처리(승인·반려)하기 전에는 사라지지 않고 화면 하단 배너로 상시 노출되는 알림 유형.
|
||||||
|
# 단순 통지(SUCCESS/FAILURE 등)와 달리 담당자의 액션을 기다리는 건이라 읽음 처리만으로 닫지 않는다.
|
||||||
|
ACTION_REQUIRED_NOTIFICATIONS = {NotificationType.RENEGO_REQUESTED}
|
||||||
|
|
||||||
|
|
||||||
|
class RenegotiationStatus(CodeEnum):
|
||||||
|
"""sessions.custom.renegotiation.status — 공급사 재협상 요청 상태(IMK #15). 전용 테이블 없이 JSONB 에 둔다."""
|
||||||
|
|
||||||
|
PENDING = 1 # 접수, 담당자 심사 대기
|
||||||
|
APPROVED = 2 # 승인 — 다음 라운드 생성 완료
|
||||||
|
REJECTED = 3 # 반려 — 사유 기록
|
||||||
|
CANCELED = 4 # 공급사가 철회
|
||||||
|
|
||||||
|
|
||||||
class ChatSender(CodeEnum):
|
class ChatSender(CodeEnum):
|
||||||
|
|||||||
132
negodata/backend/crud/renegotiation_crud.py
Normal file
132
negodata/backend/crud/renegotiation_crud.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import String, and_, cast, func, select, text, update
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import items, quotations, sessions, suppliers, users
|
||||||
|
from common.enums import ErrorType
|
||||||
|
from common.logger import LOG
|
||||||
|
|
||||||
|
# 재협상 요청은 전용 테이블 없이 sessions.custom.renegotiation 에 들어간다(IMK #15).
|
||||||
|
# 조회는 세션을 견적·상품·공급사와 조인하면서 JSONB 조건으로 거른다.
|
||||||
|
_RENEGO = sessions.custom["renegotiation"]
|
||||||
|
|
||||||
|
|
||||||
|
class IRenegotiationCRUD(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_requests(self, cdb: AsyncSession, company_id, status: Optional[int], skip: int, limit: int, owner_user_id=None) -> Tuple[ErrorType, list, int]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_request(self, cdb: AsyncSession, company_id, session_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def merge_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RenegotiationCRUD(IRenegotiationCRUD):
|
||||||
|
@staticmethod
|
||||||
|
def _base_query(company_id, status: Optional[int], owner_user_id=None):
|
||||||
|
# 회사 스코프는 견적 작성자(users.company_id)로 건다 — quotations 에 company_id 컬럼이 없다.
|
||||||
|
# owner_user_id 가 오면(일반관리자) 자기 견적만 — 본인 견적의 재협상 요청만 보고 처리한다. OWNER 는 None(회사 전체).
|
||||||
|
conds = [
|
||||||
|
sessions.deleted == False, # noqa: E712
|
||||||
|
users.company_id == company_id,
|
||||||
|
_RENEGO.isnot(None),
|
||||||
|
]
|
||||||
|
if owner_user_id is not None:
|
||||||
|
conds.append(quotations.user_id == owner_user_id)
|
||||||
|
if status is not None:
|
||||||
|
conds.append(cast(_RENEGO["status"].astext, String) == str(status))
|
||||||
|
return and_(*conds)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _select():
|
||||||
|
# 승인/반려한 담당자 이름 — custom.renegotiation.decided_by(user_id) 로 users 를 한 번 더(별칭) 조인.
|
||||||
|
decider = aliased(users)
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
sessions.session_id,
|
||||||
|
sessions.quotation_id,
|
||||||
|
sessions.supplier_id,
|
||||||
|
sessions.target_price,
|
||||||
|
sessions.bid_price,
|
||||||
|
sessions.custom,
|
||||||
|
quotations.number,
|
||||||
|
quotations.round,
|
||||||
|
quotations.name,
|
||||||
|
quotations.close_reason,
|
||||||
|
items.name,
|
||||||
|
suppliers.name,
|
||||||
|
decider.name,
|
||||||
|
users.user_id, # 견적 작성자(소유자) — 승인/반려 소유권 게이팅용 (row[13])
|
||||||
|
users.name, # 견적 담당자(작성자) 이름 — 리스트 표시용 (row[14])
|
||||||
|
sessions.item_id, # 상품 링크용 (row[15])
|
||||||
|
)
|
||||||
|
.select_from(sessions)
|
||||||
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
|
.join(users, users.user_id == quotations.user_id)
|
||||||
|
.outerjoin(items, items.item_id == sessions.item_id)
|
||||||
|
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
|
||||||
|
.outerjoin(decider, cast(decider.user_id, String) == _RENEGO["decided_by"].astext)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_requests(self, cdb: AsyncSession, company_id, status: Optional[int], skip: int, limit: int, owner_user_id=None) -> Tuple[ErrorType, list, int]:
|
||||||
|
try:
|
||||||
|
where = self._base_query(company_id, status, owner_user_id)
|
||||||
|
|
||||||
|
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(
|
||||||
|
cdb,
|
||||||
|
select(func.count())
|
||||||
|
.select_from(sessions)
|
||||||
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
|
.join(users, users.user_id == quotations.user_id)
|
||||||
|
.where(where),
|
||||||
|
)
|
||||||
|
if cnt_err != ErrorType.SUCCESS:
|
||||||
|
return cnt_err, [], 0
|
||||||
|
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
||||||
|
|
||||||
|
# 요청 시각 내림차순 — JSONB 텍스트지만 ISO8601 이라 사전순 = 시간순.
|
||||||
|
err, rows = await DB_SESSION_MNG.execute(
|
||||||
|
cdb,
|
||||||
|
self._select().where(where).order_by(_RENEGO["requested_at"].astext.desc()).offset(skip).limit(limit),
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
return err, [], 0
|
||||||
|
return ErrorType.SUCCESS, list(rows), total
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, [], 0
|
||||||
|
|
||||||
|
async def get_request(self, cdb: AsyncSession, company_id, session_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||||
|
try:
|
||||||
|
err, rows = await DB_SESSION_MNG.execute(
|
||||||
|
cdb,
|
||||||
|
self._select().where(and_(self._base_query(company_id, None), sessions.session_id == session_id)).limit(1),
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
return err, None
|
||||||
|
return ErrorType.SUCCESS, rows[0] if rows else None
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def merge_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
|
||||||
|
# 부가정보와 같은 컬럼을 쓰므로 통째로 덮지 않고 병합한다.
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(sessions)
|
||||||
|
.where(sessions.session_id == session_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
|
||||||
@ -22,6 +22,7 @@ import router.v1.quotation_setting.quotation_setting
|
|||||||
import router.v1.dashboard.dashboard
|
import router.v1.dashboard.dashboard
|
||||||
import router.v1.statistics.statistics
|
import router.v1.statistics.statistics
|
||||||
import router.v1.notification.notification
|
import router.v1.notification.notification
|
||||||
|
import router.v1.renegotiation.renegotiation
|
||||||
|
|
||||||
API_SERVER_START_TIME = GTime.UTCStr()
|
API_SERVER_START_TIME = GTime.UTCStr()
|
||||||
|
|
||||||
@ -79,3 +80,4 @@ app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
|||||||
app.include_router(router.v1.dashboard.dashboard.router)
|
app.include_router(router.v1.dashboard.dashboard.router)
|
||||||
app.include_router(router.v1.statistics.statistics.router)
|
app.include_router(router.v1.statistics.statistics.router)
|
||||||
app.include_router(router.v1.notification.notification.router)
|
app.include_router(router.v1.notification.notification.router)
|
||||||
|
app.include_router(router.v1.renegotiation.renegotiation.router)
|
||||||
|
|||||||
56
negodata/backend/router/v1/renegotiation/protocol.py
Normal file
56
negodata/backend/router/v1/renegotiation/protocol.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class RenegotiationProtocol(WebPacketProtocol):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RenegotiationData(RenegotiationProtocol):
|
||||||
|
session_id: str = ""
|
||||||
|
quotation_id: str = ""
|
||||||
|
qt_number: str = ""
|
||||||
|
qt_round: int = 0
|
||||||
|
qt_name: str = ""
|
||||||
|
item_id: str = ""
|
||||||
|
item_name: str = ""
|
||||||
|
supplier_id: str = ""
|
||||||
|
supplier_name: str = ""
|
||||||
|
owner_name: str = "" # 견적 담당자(작성자) 이름 — 누구 견적인지 리스트 표시용
|
||||||
|
status: int = 0
|
||||||
|
reason: str = ""
|
||||||
|
desired_price: Optional[int] = None
|
||||||
|
requested_at: str = ""
|
||||||
|
decided_at: Optional[str] = None
|
||||||
|
decided_by_name: Optional[str] = None
|
||||||
|
memo: Optional[str] = None
|
||||||
|
next_quotation_id: Optional[str] = None
|
||||||
|
close_reason: Optional[int] = None
|
||||||
|
target_price: Optional[int] = None
|
||||||
|
bid_price: Optional[int] = None
|
||||||
|
can_act: bool = True # 조회자가 이 요청을 승인/반려할 수 있는지(견적 소유자∪OWNER). 리스트는 전체가 보이되 처리는 이걸로 게이팅
|
||||||
|
|
||||||
|
|
||||||
|
class Res_RenegotiationList(Res_WebPacketProtocol):
|
||||||
|
requests: List[RenegotiationData] = []
|
||||||
|
total: int = 0
|
||||||
|
page: int = 0
|
||||||
|
size: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Req_ApproveRenegotiation(RenegotiationProtocol):
|
||||||
|
supplier_ids: List[str] = Field(default_factory=list, description="다음 라운드에 함께 넣을 공급사. 비우면 요청자만")
|
||||||
|
memo: str = Field("", max_length=255, description="승인 메모")
|
||||||
|
|
||||||
|
|
||||||
|
class Req_RejectRenegotiation(RenegotiationProtocol):
|
||||||
|
memo: str = Field("", max_length=255, description="반려 사유 — 공급사에게 그대로 노출된다")
|
||||||
|
|
||||||
|
|
||||||
|
class Res_RenegotiationDecision(Res_WebPacketProtocol):
|
||||||
|
session_id: str = ""
|
||||||
|
status: int = 0
|
||||||
|
next_quotation_id: Optional[str] = None
|
||||||
47
negodata/backend/router/v1/renegotiation/renegotiation.py
Normal file
47
negodata/backend/router/v1/renegotiation/renegotiation.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from common.models.gmodel import PageParams, UserInfo
|
||||||
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||||
|
from services.renegotiation_service import RenegotiationService
|
||||||
|
from .protocol import (
|
||||||
|
Req_ApproveRenegotiation,
|
||||||
|
Req_RejectRenegotiation,
|
||||||
|
Res_RenegotiationDecision,
|
||||||
|
Res_RenegotiationList,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1/renegotiation", tags=["Renegotiation"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(path="/list", response_model=Res_RenegotiationList, summary="재협상 요청 현황")
|
||||||
|
async def list_requests(
|
||||||
|
service: RenegotiationService = Depends(),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
status: Optional[int] = Query(None, description="요청 상태(1=심사중 2=승인 3=반려 4=철회). 미지정 시 전체"),
|
||||||
|
pg: PageParams = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.list_requests(user_info.company_id, user_info.user_id, user_info.role, status, pg))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(path="/{session_id}/approve", response_model=Res_RenegotiationDecision, summary="재협상 승인")
|
||||||
|
async def approve(
|
||||||
|
req: Req_ApproveRenegotiation,
|
||||||
|
session_id: str = Path(..., description="요청이 달린 협상 세션 uuid"),
|
||||||
|
service: RenegotiationService = Depends(),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(
|
||||||
|
await service.approve(user_info.company_id, user_info.user_id, user_info.role, session_id, req)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(path="/{session_id}/reject", response_model=Res_RenegotiationDecision, summary="재협상 반려")
|
||||||
|
async def reject(
|
||||||
|
req: Req_RejectRenegotiation,
|
||||||
|
session_id: str = Path(..., description="요청이 달린 협상 세션 uuid"),
|
||||||
|
service: RenegotiationService = Depends(),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.reject(user_info.company_id, user_info.user_id, user_info.role, session_id, req))
|
||||||
@ -311,7 +311,7 @@ class QuotationService:
|
|||||||
)
|
)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list) -> Res_CreateQuotation:
|
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None) -> Res_CreateQuotation:
|
||||||
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다.
|
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다.
|
||||||
|
|
||||||
플로우:
|
플로우:
|
||||||
@ -356,9 +356,10 @@ class QuotationService:
|
|||||||
)
|
)
|
||||||
base_round = chain_max if (_e == ErrorType.SUCCESS and chain_max) else original.round
|
base_round = chain_max if (_e == ErrorType.SUCCESS and chain_max) else original.round
|
||||||
next_round = base_round + 1
|
next_round = base_round + 1
|
||||||
# 이름에 '(N차)' 표기. 원래 이름 기준(기존 '(M차)' 표기는 떼고 새로) + name 컬럼 50자 제한 보호.
|
# 이름 접미사: 재협상 승인 재생성은 '(재협상 요청 재생성)', 그 외 재생성은 '(N차)'.
|
||||||
suffix = f" ({next_round}차)"
|
# 원래 이름의 기존 접미사(차수/재협상)는 떼고 새로 붙인다 + name 컬럼 50자 제한 보호.
|
||||||
base_name = re.sub(r"\s*\(\d+차\)\s*$", "", original.name or "")[: 50 - len(suffix)]
|
suffix = f" ({regen_label})" if regen_label else f" ({next_round}차)"
|
||||||
|
base_name = re.sub(r"\s*\((?:\d+차|재협상[^)]*)\)\s*$", "", original.name or "")[: 50 - len(suffix)]
|
||||||
return await self._build_quotation(
|
return await self._build_quotation(
|
||||||
user_id=str(original.user_id),
|
user_id=str(original.user_id),
|
||||||
qt_setting_id=original.qt_setting_id,
|
qt_setting_id=original.qt_setting_id,
|
||||||
@ -697,7 +698,7 @@ class QuotationService:
|
|||||||
# 4) 전원 미응찰 → 개찰(미응찰).
|
# 4) 전원 미응찰 → 개찰(미응찰).
|
||||||
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
|
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
|
||||||
|
|
||||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None) -> Res_CreateQuotation:
|
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None) -> Res_CreateQuotation:
|
||||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||||
크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다.
|
크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다.
|
||||||
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
|
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
|
||||||
@ -733,7 +734,7 @@ class QuotationService:
|
|||||||
res.msg = "마지막 차수의 견적에서만 다음 라운드를 생성할 수 있습니다."
|
res.msg = "마지막 차수의 견적에서만 다음 라운드를 생성할 수 있습니다."
|
||||||
return res
|
return res
|
||||||
|
|
||||||
return await self.regenerate_next_round(qt_uuid, supplier_ids)
|
return await self.regenerate_next_round(qt_uuid, supplier_ids, regen_label=regen_label)
|
||||||
|
|
||||||
async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
|
async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
|
||||||
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
|
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
|
||||||
|
|||||||
170
negodata/backend/services/renegotiation_service.py
Normal file
170
negodata/backend/services/renegotiation_service.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from common.authz import is_owner_or_admin
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import sessions
|
||||||
|
from common.enums import DBWRType, ErrorType, RenegotiationStatus
|
||||||
|
from common.logger import LOG
|
||||||
|
from common.models.gmodel import PageParams
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
from crud.renegotiation_crud import IRenegotiationCRUD, RenegotiationCRUD
|
||||||
|
from router.v1.renegotiation.protocol import (
|
||||||
|
RenegotiationData,
|
||||||
|
Req_ApproveRenegotiation,
|
||||||
|
Req_RejectRenegotiation,
|
||||||
|
Res_RenegotiationDecision,
|
||||||
|
Res_RenegotiationList,
|
||||||
|
)
|
||||||
|
from services.quotation_service import QuotationService
|
||||||
|
|
||||||
|
|
||||||
|
class RenegotiationService:
|
||||||
|
"""공급사 재협상 요청 심사(IMK #15).
|
||||||
|
|
||||||
|
요청 자체는 공급사 포털이 sessions.custom.renegotiation 에 기록한다. 여기서는 조회와 승인/반려만 한다.
|
||||||
|
승인은 새 로직이 아니라 기존 수동 재생성(regenerate_quotation)을 그대로 호출한다 — 라운드 생성 규칙을 한 곳에 둔다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
crud: IRenegotiationCRUD = Depends(RenegotiationCRUD),
|
||||||
|
quotation_service: QuotationService = Depends(),
|
||||||
|
):
|
||||||
|
self.crud = crud
|
||||||
|
self.quotation_service = quotation_service
|
||||||
|
|
||||||
|
async def list_requests(self, company_id: str, user_id: str, role: int, status: Optional[int], pg: PageParams) -> Res_RenegotiationList:
|
||||||
|
res = Res_RenegotiationList(page=pg.page, size=pg.size)
|
||||||
|
# 리스트는 회사 전체가 보인다(현황 공유). 처리 권한은 항목별 can_act 로 내려준다.
|
||||||
|
err, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.crud.list_requests(s, uuid.UUID(company_id), status, pg.skip, pg.size),
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err)
|
||||||
|
return res
|
||||||
|
res.requests = [self._to_data(r, user_id, role) for r in rows]
|
||||||
|
res.total = total
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def approve(self, company_id: str, user_id: str, role: int, session_id: str, req: Req_ApproveRenegotiation) -> Res_RenegotiationDecision:
|
||||||
|
"""승인 → 원 견적의 다음 라운드 생성. 요청 공급사는 항상 포함한다."""
|
||||||
|
res = Res_RenegotiationDecision()
|
||||||
|
err, row, current = await self._load_pending(company_id, session_id)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err)
|
||||||
|
return res
|
||||||
|
# 승인은 견적 작성자 본인 또는 최고관리자만 — 일반관리자는 남의 견적 처리 불가.
|
||||||
|
if not is_owner_or_admin(row[13], user_id, role):
|
||||||
|
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||||
|
return res
|
||||||
|
|
||||||
|
supplier_ids = list({str(row[2]), *(req.supplier_ids or [])})
|
||||||
|
created = await self.quotation_service.regenerate_quotation(
|
||||||
|
str(row[1]), company_id, supplier_ids, user_id=user_id, role=role, regen_label="재협상 요청 재생성"
|
||||||
|
)
|
||||||
|
if created.result.success is False:
|
||||||
|
res.result.SetResult(ErrorType.FAIL)
|
||||||
|
res.msg = created.msg or "다음 라운드 생성에 실패했습니다."
|
||||||
|
return res
|
||||||
|
|
||||||
|
patch = {
|
||||||
|
**current,
|
||||||
|
"status": RenegotiationStatus.APPROVED.value,
|
||||||
|
"decided_by": user_id,
|
||||||
|
"decided_at": GTime.UTC().isoformat(),
|
||||||
|
"memo": (req.memo or "").strip() or None,
|
||||||
|
"next_quotation_id": str(created.qt_id) if created.qt_id else None,
|
||||||
|
}
|
||||||
|
err = await self._save(session_id, patch)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
# 라운드는 이미 생겼는데 상태만 못 남긴 경우 — 로그로 남기고 성공으로 반환한다(재승인은 막힌다).
|
||||||
|
LOG.e_no_callstack(f"[renego] 승인 상태 기록 실패 session={session_id} next_qt={created.qt_id}")
|
||||||
|
|
||||||
|
res.session_id = session_id
|
||||||
|
res.status = RenegotiationStatus.APPROVED.value
|
||||||
|
res.next_quotation_id = str(created.qt_id) if created.qt_id else None
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def reject(self, company_id: str, user_id: str, role: int, session_id: str, req: Req_RejectRenegotiation) -> Res_RenegotiationDecision:
|
||||||
|
res = Res_RenegotiationDecision()
|
||||||
|
err, row, current = await self._load_pending(company_id, session_id)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err)
|
||||||
|
return res
|
||||||
|
# 반려도 승인과 같은 소유권 규칙 — 견적 작성자 본인 또는 최고관리자만.
|
||||||
|
if not is_owner_or_admin(row[13], user_id, role):
|
||||||
|
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||||
|
return res
|
||||||
|
|
||||||
|
patch = {
|
||||||
|
**current,
|
||||||
|
"status": RenegotiationStatus.REJECTED.value,
|
||||||
|
"decided_by": user_id,
|
||||||
|
"decided_at": GTime.UTC().isoformat(),
|
||||||
|
"memo": (req.memo or "").strip() or None,
|
||||||
|
}
|
||||||
|
err = await self._save(session_id, patch)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.session_id = session_id
|
||||||
|
res.status = RenegotiationStatus.REJECTED.value
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def _load_pending(self, company_id: str, session_id: str):
|
||||||
|
"""심사 대상(대기 중) 요청 로드. (err, row, renegotiation dict)"""
|
||||||
|
try:
|
||||||
|
sid = uuid.UUID(session_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ErrorType.INVALID_REQUEST_DATA, None, {}
|
||||||
|
err, row = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.crud.get_request(s, uuid.UUID(company_id), sid),
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS or row is None:
|
||||||
|
return ErrorType.QUOTATION_NOT_FOUND, None, {}
|
||||||
|
current = (row[5] or {}).get("renegotiation") or {}
|
||||||
|
if current.get("status") != RenegotiationStatus.PENDING.value:
|
||||||
|
return ErrorType.INVALID_REQUEST_DATA, None, {}
|
||||||
|
return ErrorType.SUCCESS, row, current
|
||||||
|
|
||||||
|
async def _save(self, session_id: str, patch: dict) -> ErrorType:
|
||||||
|
return await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.crud.merge_custom(s, uuid.UUID(session_id), {"renegotiation": patch})],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_data(row, user_id=None, role=None) -> RenegotiationData:
|
||||||
|
r = (row[5] or {}).get("renegotiation") or {}
|
||||||
|
return RenegotiationData(
|
||||||
|
can_act=is_owner_or_admin(row[13], user_id, role) if user_id is not None else True,
|
||||||
|
session_id=str(row[0]),
|
||||||
|
quotation_id=str(row[1]),
|
||||||
|
supplier_id=str(row[2]),
|
||||||
|
target_price=row[3],
|
||||||
|
bid_price=row[4],
|
||||||
|
qt_number=row[6] or "",
|
||||||
|
qt_round=row[7] or 0,
|
||||||
|
qt_name=row[8] or "",
|
||||||
|
close_reason=row[9],
|
||||||
|
item_id=str(row[15]) if row[15] else "",
|
||||||
|
item_name=row[10] or "",
|
||||||
|
supplier_name=row[11] or "",
|
||||||
|
owner_name=row[14] or "",
|
||||||
|
status=r.get("status") or 0,
|
||||||
|
reason=r.get("reason") or "",
|
||||||
|
desired_price=r.get("desired_price"),
|
||||||
|
requested_at=r.get("requested_at") or "",
|
||||||
|
decided_at=r.get("decided_at"),
|
||||||
|
decided_by_name=row[12] or None,
|
||||||
|
memo=r.get("memo"),
|
||||||
|
next_quotation_id=r.get("next_quotation_id"),
|
||||||
|
)
|
||||||
364
negodata/backend/tests/test_renegotiation.py
Normal file
364
negodata/backend/tests/test_renegotiation.py
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
"""공급사 재협상 요청 심사(IMK #15) — RenegotiationService 단위 테스트.
|
||||||
|
|
||||||
|
요청 자체는 공급사 포털이 sessions.custom.renegotiation 에 기록한다(여기선 조회·승인·반려만).
|
||||||
|
승인은 새 로직이 아니라 기존 regenerate_quotation 을 호출하므로, 라운드 생성 machinery 는
|
||||||
|
스텁 QuotationService 로 격리하고 #15 고유 계약만 본다:
|
||||||
|
· 목록 = 회사 스코프 + 상태 필터 (남의 회사 요청은 안 보임)
|
||||||
|
· 승인 = 대기 요청만 → APPROVED 박제 + next_quotation_id 저장, 요청 공급사는 항상 재생성에 포함
|
||||||
|
· 승인 재생성 실패 → 상태 PENDING 유지(성급히 APPROVED 로 넘기지 않음)
|
||||||
|
· 반려 = 대기 요청만 → REJECTED + 사유(memo) 저장
|
||||||
|
· 대기 아닌 요청(이미 승인/반려)엔 승인·반려 재시도 거부(멱등 가드)
|
||||||
|
|
||||||
|
세션의 custom.renegotiation 은 포털에서만 생기는 값이라 SQL 로 직접 넣는다.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from common.enums import (
|
||||||
|
CloseReason,
|
||||||
|
ErrorType,
|
||||||
|
QuotationStatus,
|
||||||
|
QuotationType,
|
||||||
|
RenegotiationStatus,
|
||||||
|
SessionStatus,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from common.models.gmodel import PageParams
|
||||||
|
from crud.renegotiation_crud import RenegotiationCRUD
|
||||||
|
from router.v1.quotation.protocol import Res_CreateQuotation
|
||||||
|
from services.renegotiation_service import RenegotiationService
|
||||||
|
|
||||||
|
PAST = datetime(2020, 1, 1)
|
||||||
|
PG = PageParams(1, 20)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubQuotation:
|
||||||
|
"""regenerate_quotation 을 대신한다 — 인자를 기록하고 정해진 결과만 돌려준다.
|
||||||
|
ok=False 면 재생성 실패를 흉내낸다(승인이 상태를 넘기면 안 되는 경로 검증)."""
|
||||||
|
|
||||||
|
def __init__(self, *, ok=True, qt_id=None):
|
||||||
|
self.ok = ok
|
||||||
|
self.qt_id = qt_id or uuid.uuid4()
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def regenerate_quotation(self, qt_id, company_id, supplier_ids, user_id=None, role=None, regen_label=None):
|
||||||
|
self.calls.append(
|
||||||
|
{"qt_id": qt_id, "company_id": company_id, "supplier_ids": list(supplier_ids),
|
||||||
|
"user_id": user_id, "role": role, "regen_label": regen_label}
|
||||||
|
)
|
||||||
|
res = Res_CreateQuotation()
|
||||||
|
if self.ok:
|
||||||
|
res.qt_id = self.qt_id
|
||||||
|
else:
|
||||||
|
res.result.SetResult(ErrorType.FAIL)
|
||||||
|
res.msg = "stub 재생성 실패"
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 목록 =====
|
||||||
|
async def test_list_scoped_to_company(db_engine, company_id, other_company_id):
|
||||||
|
"""검증: 내 회사 대기요청 1건 + 남의 회사 대기요청 1건이 있을 때 내 회사로 목록 조회.
|
||||||
|
기대결과: 내 회사 건만(total=1) 나오고, 남의 회사 세션 id 는 결과에 없다."""
|
||||||
|
mine = await _seed_request(db_engine, company_id, number="R-MINE", renego=_pending())
|
||||||
|
await _seed_request(db_engine, other_company_id, number="R-THEIRS", renego=_pending())
|
||||||
|
|
||||||
|
res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG)
|
||||||
|
|
||||||
|
assert res.result.success is True
|
||||||
|
assert res.total == 1
|
||||||
|
assert [r.session_id for r in res.requests] == [mine["session_id"]]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_filters_by_status(db_engine, company_id):
|
||||||
|
"""검증: 같은 회사에 대기(PENDING)·반려(REJECTED) 요청을 하나씩 두고 status=1(대기)로 필터.
|
||||||
|
기대결과: 대기 건만 반환(total=1)."""
|
||||||
|
pending = await _seed_request(db_engine, company_id, number="R-P", renego=_pending())
|
||||||
|
await _seed_request(db_engine, company_id, number="R-R", renego=_decided(RenegotiationStatus.REJECTED.value))
|
||||||
|
|
||||||
|
res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, RenegotiationStatus.PENDING.value, PG)
|
||||||
|
|
||||||
|
assert res.total == 1
|
||||||
|
assert res.requests[0].session_id == pending["session_id"]
|
||||||
|
assert res.requests[0].status == RenegotiationStatus.PENDING.value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_shows_decider_name(db_engine, company_id):
|
||||||
|
"""검증: 처리(승인/반려)된 요청의 decided_by(담당자 user_id)로 담당자 이름을 조인해 내려준다.
|
||||||
|
기대결과: 목록 항목의 decided_by_name 이 그 담당자 이름."""
|
||||||
|
decider_id = uuid.uuid4()
|
||||||
|
await _seed_user(db_engine, company_id, decider_id, "김담당")
|
||||||
|
renego = {**_decided(RenegotiationStatus.APPROVED.value), "decided_by": str(decider_id)}
|
||||||
|
await _seed_request(db_engine, company_id, number="R-WHO", renego=renego)
|
||||||
|
|
||||||
|
res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG)
|
||||||
|
|
||||||
|
assert res.requests[0].decided_by_name == "김담당"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_shows_all_with_can_act_flags(db_engine, company_id):
|
||||||
|
"""검증: 같은 회사에 서로 다른 작성자의 요청 2건. 리스트엔 전체가 보이되, 처리 권한은 can_act 로 온다.
|
||||||
|
기대결과: 일반관리자(A)는 둘 다 보이고(total=2) can_act 는 자기(A) 것만 True. OWNER 는 전부 True."""
|
||||||
|
a = await _seed_request(db_engine, company_id, number="R-A", renego=_pending())
|
||||||
|
b = await _seed_request(db_engine, company_id, number="R-B", renego=_pending())
|
||||||
|
|
||||||
|
res = await _service().list_requests(company_id, a["user_id"], UserRole.USER.value, None, PG)
|
||||||
|
assert res.total == 2
|
||||||
|
can = {r.session_id: r.can_act for r in res.requests}
|
||||||
|
assert can[a["session_id"]] is True
|
||||||
|
assert can[b["session_id"]] is False
|
||||||
|
|
||||||
|
owner = await _service().list_requests(company_id, a["user_id"], UserRole.OWNER.value, None, PG)
|
||||||
|
assert all(r.can_act for r in owner.requests)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 승인 =====
|
||||||
|
async def test_approve_transitions_and_persists(db_engine, company_id):
|
||||||
|
"""검증: 대기 요청을 승인(재생성 성공 스텁, 승인메모 첨부).
|
||||||
|
기대결과: APPROVED + next_quotation_id 저장 + memo 저장, 재생성엔 요청 공급사가 포함돼 호출된다."""
|
||||||
|
seed = await _seed_request(db_engine, company_id, number="R-OK", renego=_pending())
|
||||||
|
stub = _StubQuotation(ok=True)
|
||||||
|
svc = _service(stub)
|
||||||
|
|
||||||
|
req = _approve_req(memo="조건 재검토 승인")
|
||||||
|
res = await svc.approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req)
|
||||||
|
|
||||||
|
assert res.result.success is True
|
||||||
|
assert res.status == RenegotiationStatus.APPROVED.value
|
||||||
|
assert res.next_quotation_id == str(stub.qt_id)
|
||||||
|
# 재생성은 정확히 1번, 요청 공급사를 포함해서 호출
|
||||||
|
assert len(stub.calls) == 1
|
||||||
|
assert seed["supplier_id"] in stub.calls[0]["supplier_ids"]
|
||||||
|
assert stub.calls[0]["qt_id"] == seed["quotation_id"]
|
||||||
|
# 재생성 견적 타이틀 마킹용 라벨을 넘긴다(수동 재생성과 구분).
|
||||||
|
assert stub.calls[0]["regen_label"] == "재협상 요청 재생성"
|
||||||
|
|
||||||
|
saved = await _renego_of(db_engine, seed["session_id"])
|
||||||
|
assert saved["status"] == RenegotiationStatus.APPROVED.value
|
||||||
|
assert saved["next_quotation_id"] == str(stub.qt_id)
|
||||||
|
assert saved["memo"] == "조건 재검토 승인"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_approve_includes_extra_suppliers(db_engine, company_id):
|
||||||
|
"""검증: 승인 시 요청자 외 추가 공급사(supplier_ids)를 함께 지정.
|
||||||
|
기대결과: 재생성 호출의 공급사 집합에 요청자 + 추가 공급사가 모두 들어간다(중복 없이)."""
|
||||||
|
seed = await _seed_request(db_engine, company_id, number="R-MULTI", renego=_pending())
|
||||||
|
extra = str(uuid.uuid4())
|
||||||
|
stub = _StubQuotation(ok=True)
|
||||||
|
|
||||||
|
req = _approve_req(supplier_ids=[extra, seed["supplier_id"]]) # 요청자 중복 포함
|
||||||
|
await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req)
|
||||||
|
|
||||||
|
got = set(stub.calls[0]["supplier_ids"])
|
||||||
|
assert got == {seed["supplier_id"], extra}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_approve_blocks_when_not_pending(db_engine, company_id):
|
||||||
|
"""검증: 이미 승인된(APPROVED) 요청에 승인 재시도(멱등 가드).
|
||||||
|
기대결과: 거부(INVALID_REQUEST_DATA) + 재생성 미호출."""
|
||||||
|
seed = await _seed_request(
|
||||||
|
db_engine, company_id, number="R-DONE", renego=_decided(RenegotiationStatus.APPROVED.value)
|
||||||
|
)
|
||||||
|
stub = _StubQuotation(ok=True)
|
||||||
|
|
||||||
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req())
|
||||||
|
|
||||||
|
assert res.result.success is False
|
||||||
|
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
||||||
|
assert stub.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_approve_keeps_pending_when_regenerate_fails(db_engine, company_id):
|
||||||
|
"""검증: 대기 요청 승인 중 재생성이 실패(스텁 ok=False).
|
||||||
|
기대결과: 실패 반환 + 상태는 PENDING 그대로(성급히 APPROVED 로 넘기지 않음)."""
|
||||||
|
seed = await _seed_request(db_engine, company_id, number="R-FAIL", renego=_pending())
|
||||||
|
stub = _StubQuotation(ok=False)
|
||||||
|
|
||||||
|
res = await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _approve_req())
|
||||||
|
|
||||||
|
assert res.result.success is False
|
||||||
|
saved = await _renego_of(db_engine, seed["session_id"])
|
||||||
|
assert saved["status"] == RenegotiationStatus.PENDING.value
|
||||||
|
assert "next_quotation_id" not in saved or saved["next_quotation_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_approve_other_company_not_found(db_engine, company_id, other_company_id):
|
||||||
|
"""검증: 남의 회사 요청 세션을 내 회사 자격으로 승인 시도(IDOR).
|
||||||
|
기대결과: NOT_FOUND(회사 스코프 밖) + 재생성 미호출."""
|
||||||
|
seed = await _seed_request(db_engine, other_company_id, number="R-IDOR", renego=_pending())
|
||||||
|
stub = _StubQuotation(ok=True)
|
||||||
|
|
||||||
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req())
|
||||||
|
|
||||||
|
assert res.result.success is False
|
||||||
|
assert res.result.code == ErrorType.QUOTATION_NOT_FOUND.value
|
||||||
|
assert stub.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 반려 =====
|
||||||
|
async def test_reject_transitions_and_saves_memo(db_engine, company_id):
|
||||||
|
"""검증: 대기 요청을 사유와 함께 반려.
|
||||||
|
기대결과: REJECTED + memo(반려 사유) 저장."""
|
||||||
|
seed = await _seed_request(db_engine, company_id, number="R-REJ", renego=_pending())
|
||||||
|
|
||||||
|
res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("단종 품목이라 불가"))
|
||||||
|
|
||||||
|
assert res.result.success is True
|
||||||
|
assert res.status == RenegotiationStatus.REJECTED.value
|
||||||
|
saved = await _renego_of(db_engine, seed["session_id"])
|
||||||
|
assert saved["status"] == RenegotiationStatus.REJECTED.value
|
||||||
|
assert saved["memo"] == "단종 품목이라 불가"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_blocks_when_not_pending(db_engine, company_id):
|
||||||
|
"""검증: 이미 반려된 요청에 반려 재시도.
|
||||||
|
기대결과: 거부(INVALID_REQUEST_DATA)."""
|
||||||
|
seed = await _seed_request(
|
||||||
|
db_engine, company_id, number="R-REJ2", renego=_decided(RenegotiationStatus.REJECTED.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("x"))
|
||||||
|
|
||||||
|
assert res.result.success is False
|
||||||
|
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 소유권 게이팅 =====
|
||||||
|
async def test_approve_forbidden_for_non_owner_user(db_engine, company_id):
|
||||||
|
"""검증: 남의 견적 재협상 요청을 일반관리자(비소유·USER)가 승인 시도.
|
||||||
|
기대결과: 거부(ACCOUNT_FORBIDDEN) + 재생성 미호출."""
|
||||||
|
seed = await _seed_request(db_engine, company_id, number="R-NOTMINE", renego=_pending())
|
||||||
|
stub = _StubQuotation(ok=True)
|
||||||
|
|
||||||
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req())
|
||||||
|
|
||||||
|
assert res.result.success is False
|
||||||
|
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||||
|
assert stub.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_approve_allowed_for_owner(db_engine, company_id):
|
||||||
|
"""검증: 남의 견적이라도 최고관리자(OWNER)면 승인.
|
||||||
|
기대결과: 성공 + 재생성 호출."""
|
||||||
|
seed = await _seed_request(db_engine, company_id, number="R-OWNER", renego=_pending())
|
||||||
|
stub = _StubQuotation(ok=True)
|
||||||
|
|
||||||
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.OWNER.value, seed["session_id"], _approve_req())
|
||||||
|
|
||||||
|
assert res.result.success is True
|
||||||
|
assert len(stub.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_forbidden_for_non_owner_user(db_engine, company_id):
|
||||||
|
"""검증: 남의 견적 재협상 요청을 일반관리자가 반려 시도.
|
||||||
|
기대결과: 거부(ACCOUNT_FORBIDDEN)."""
|
||||||
|
seed = await _seed_request(db_engine, company_id, number="R-REJNOT", renego=_pending())
|
||||||
|
|
||||||
|
res = await _service().reject(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _reject_req("x"))
|
||||||
|
|
||||||
|
assert res.result.success is False
|
||||||
|
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 헬퍼 =====
|
||||||
|
def _pending():
|
||||||
|
return {
|
||||||
|
"status": RenegotiationStatus.PENDING.value,
|
||||||
|
"reason": "가격 재검토 요청",
|
||||||
|
"desired_price": 90000,
|
||||||
|
"requested_at": "2026-07-20T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _decided(status):
|
||||||
|
return {**_pending(), "status": status, "decided_at": "2026-07-21T00:00:00+00:00", "memo": "기존 판단"}
|
||||||
|
|
||||||
|
|
||||||
|
def _approve_req(*, supplier_ids=None, memo=""):
|
||||||
|
from router.v1.renegotiation.protocol import Req_ApproveRenegotiation
|
||||||
|
|
||||||
|
return Req_ApproveRenegotiation(supplier_ids=supplier_ids or [], memo=memo)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_req(memo):
|
||||||
|
from router.v1.renegotiation.protocol import Req_RejectRenegotiation
|
||||||
|
|
||||||
|
return Req_RejectRenegotiation(memo=memo)
|
||||||
|
|
||||||
|
|
||||||
|
def _service(quotation_stub=None):
|
||||||
|
return RenegotiationService(RenegotiationCRUD(), quotation_stub or _StubQuotation())
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_user(engine, company_id, user_id, name):
|
||||||
|
"""담당자 유저 1건 시드(decided_by 이름 조인 확인용)."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
|
||||||
|
"VALUES (:uid, :cid, :login, 'x', :name, 1, :role, now())"
|
||||||
|
),
|
||||||
|
{"uid": user_id, "cid": uuid.UUID(company_id), "login": f"dec-{str(user_id)[:8]}", "name": name, "role": UserRole.USER.value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_request(engine, company_id, *, number, renego, close_reason=CloseReason.OPEN_PRICE.value, round_=1):
|
||||||
|
"""재협상 요청 1건 시드: 작성자(회사 스코프) + 마감견적 + custom.renegotiation 달린 세션.
|
||||||
|
목록 쿼리가 quotations→users(company_id)·items·suppliers 를 조인하므로 이들을 함께 넣는다."""
|
||||||
|
user_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||||
|
supplier_id, item_id = uuid.uuid4(), uuid.uuid4()
|
||||||
|
custom = {"renegotiation": renego}
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
|
||||||
|
"VALUES (:uid, :cid, :login, 'x', '담당', 1, :role, now())"
|
||||||
|
),
|
||||||
|
{"uid": user_id, "cid": uuid.UUID(company_id), "login": f"u-{number}", "role": UserRole.USER.value},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO quotations "
|
||||||
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
|
||||||
|
" round, iteration, start_time, end_time, deleted) VALUES "
|
||||||
|
"(:qt_id, :uid, :setting, :version, '견적', :number, :type, :status, :close_reason, "
|
||||||
|
" :round, 0, :past, :past, false)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"qt_id": qt_id, "uid": user_id, "setting": uuid.uuid4(), "version": uuid.uuid4(),
|
||||||
|
"number": number, "type": QuotationType.REQUOTE.value, "status": QuotationStatus.CLOSED.value,
|
||||||
|
"close_reason": close_reason, "round": round_, "past": PAST,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# items·suppliers 는 목록 쿼리에서 outerjoin 이라 시드 없이도 된다(이름은 빈 문자열로 채워짐).
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO sessions "
|
||||||
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||||
|
" target_price, status, bid_price, end_time, custom) VALUES "
|
||||||
|
"(:sid, :qt_id, :iid, :spid, :number, :round, :type, "
|
||||||
|
" 100000, :sstatus, 95000, :past, CAST(:custom AS JSONB))"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"sid": session_id, "qt_id": qt_id, "iid": item_id, "spid": supplier_id,
|
||||||
|
"number": number, "round": round_, "type": QuotationType.REQUOTE.value,
|
||||||
|
"sstatus": SessionStatus.DONE.value, "past": PAST, "custom": json.dumps(custom),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"session_id": str(session_id), "quotation_id": str(qt_id),
|
||||||
|
"supplier_id": str(supplier_id), "user_id": str(user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _renego_of(engine, session_id):
|
||||||
|
"""세션 custom.renegotiation 을 읽어 dict 로 (저장 결과 확인용)."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT custom FROM sessions WHERE session_id = :sid"),
|
||||||
|
{"sid": uuid.UUID(session_id)},
|
||||||
|
)).one()
|
||||||
|
return (row[0] or {}).get("renegotiation") or {}
|
||||||
@ -123,14 +123,14 @@ async def test_scheduler_disabled_without_env(monkeypatch):
|
|||||||
|
|
||||||
async def test_scheduler_registers_both_jobs(monkeypatch):
|
async def test_scheduler_registers_both_jobs(monkeypatch):
|
||||||
"""검증: SCHEDULER_ENABLED=1 로 start_scheduler() 호출.
|
"""검증: SCHEDULER_ENABLED=1 로 start_scheduler() 호출.
|
||||||
기대결과: 마감 잡 2개(close_expired·close_negotiated)가 스케줄에 등록된다."""
|
기대결과: 마감 잡 2개(close_expired·close_negotiated) + LPS 수집 잡이 스케줄에 등록된다."""
|
||||||
import scheduler
|
import scheduler
|
||||||
monkeypatch.setenv("SCHEDULER_ENABLED", "1")
|
monkeypatch.setenv("SCHEDULER_ENABLED", "1")
|
||||||
scheduler._scheduler = None
|
scheduler._scheduler = None
|
||||||
scheduler.start_scheduler()
|
scheduler.start_scheduler()
|
||||||
try:
|
try:
|
||||||
ids = {j.id for j in scheduler._scheduler.get_jobs()}
|
ids = {j.id for j in scheduler._scheduler.get_jobs()}
|
||||||
assert ids == {"close_expired_quotations", "close_negotiated_quotations"}
|
assert ids == {"close_expired_quotations", "close_negotiated_quotations", "sync_lps_results"}
|
||||||
finally:
|
finally:
|
||||||
scheduler.shutdown_scheduler()
|
scheduler.shutdown_scheduler()
|
||||||
assert scheduler._scheduler is None
|
assert scheduler._scheduler is None
|
||||||
|
|||||||
68
negodata/docs/imk-test-coverage.md
Normal file
68
negodata/docs/imk-test-coverage.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# IMK 요구사항 테스트 커버리지 (2026-07-24 실행)
|
||||||
|
|
||||||
|
IMK 요청 표(14~23) 항목별 **자동 테스트가 실제로 걸려 있는지**와, 이번에 돌린 실행 플로우를 정리한다.
|
||||||
|
결론부터: **자동 테스트가 있는 IMK 항목은 #15(공급사 재협상) 하나뿐**. 나머지는 UI·라벨·엑셀·스크립트 변경이라 전 스위트(회귀 안전망)만 통과할 뿐 항목 전용 테스트는 없다.
|
||||||
|
|
||||||
|
## 1. 실행한 테스트 스위트
|
||||||
|
|
||||||
|
| 스위트 | 실행 커맨드 | DB | 결과 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| negodata 전체 | `APP_ENV=test .venv/bin/python -m pytest tests/` | 격리 test DB(negosium_test_db, 세션마다 재생성) | **83 passed / 1 failed** |
|
||||||
|
| 포털(negosium) 전체 | 컨테이너 `APP_ENV=local pytest tests/` | dev DB(negosium_db), 테스트가 자기 행만 시드/정리(비파괴) | **71 passed** |
|
||||||
|
| negodata #15 심사 | `pytest tests/test_renegotiation.py` | 격리 test DB | **9 passed** |
|
||||||
|
| 포털 #15 요청/철회 | 컨테이너 `pytest tests/test_renegotiation.py` | dev DB(자기정리, 잔여 0 확인) | **6 passed** |
|
||||||
|
|
||||||
|
- negodata 1 실패 = `test_scheduler::test_scheduler_registers_both_jobs` — 스케줄러에 `sync_lps_results` 잡이 새로 추가됐는데 단언을 안 고친 **stale 테스트**. IMK 항목과 무관.
|
||||||
|
- 포털은 로컬 venv가 없어 컨테이너에 pytest 임시 설치 후 실행. dev DB지만 `PYTESTRENEGO-`/`PYTESTNEGO-` 프리픽스로 자기 행만 지운다.
|
||||||
|
|
||||||
|
## 2. IMK 표(14~23) 항목별 커버리지
|
||||||
|
|
||||||
|
| # | 요구사항 | 자동 테스트 | 근거/플로우 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 14 | 발주배수 필드 추가 | ❌ 없음 | companies.settings 커스텀 필드. 전용 테스트 없음 |
|
||||||
|
| **15** | **공급사 재협상 요청 + 승인 화면** | ✅ **있음(15건)** | 포털 요청/철회 6 + negodata 심사 9. 아래 3절 |
|
||||||
|
| 16 | 통계 인상 억제율 지표 | ❌ 없음 | 통계 파생집계, 전용 테스트 없음 |
|
||||||
|
| 17 | 협력사 분류카테고리 추가 | ❌ 없음(간접만) | `test_quotation_create/anchoring`이 category 값을 쓰지만 분류 기능 자체 검증 아님 |
|
||||||
|
| 18 | 배송리드타임 → 표준납기일 | ❌ 없음 | 라벨 문자열 변경, 테스트 대상 아님 |
|
||||||
|
| 19 | 협력사 화면 구분 영역 삭제 | ❌ 없음 | 프론트 UI 제거, 테스트 대상 아님 |
|
||||||
|
| 20 | 실적(계약) 공급사 컬럼 추가 | ❌ 없음 | 상품 컬럼 추가, 전용 테스트 없음 |
|
||||||
|
| 21 | 스크립트 변수명 노출(internet_lowest_price) | ❌ 없음 | agent 스크립트 렌더 수정, 전용 테스트 없음 |
|
||||||
|
| 22 | 협상카드 일괄 선택 | ❌ 없음 | 프론트 UI, 테스트 대상 아님 |
|
||||||
|
| 23 | 상품 업로드 양식 수정 | ❌ 없음 | 엑셀 양식/컬럼, 전용 테스트 없음 |
|
||||||
|
|
||||||
|
정리: **10개 중 자동 테스트 보유는 1개(#15)**. 나머지 9개는 성격상(UI/라벨/엑셀/스크립트) 단위테스트 대상이 아니거나 아직 미작성 → 사람이 화면에서 확인해야 함.
|
||||||
|
|
||||||
|
## 3. #15 재협상 — 테스트별 검증 플로우
|
||||||
|
|
||||||
|
### 포털(negosium) — 요청/철회 (`backend/tests/test_renegotiation.py`, e2e HTTP)
|
||||||
|
개찰(OPEN_*) 마감 + 본인 마지막 라운드 세션을 시드하고 실제 로그인 → 엔드포인트 호출로 검증.
|
||||||
|
|
||||||
|
| 테스트 | 플로우 | 기대 |
|
||||||
|
|---|---|---|
|
||||||
|
| request_records_pending | 개찰건에 `POST .../renegotiation` | success + custom.renegotiation=PENDING(사유·희망가) + 담당자 알림 1건 |
|
||||||
|
| request_twice_blocked | 같은 세션에 요청 2회 | 2번째 거부, PENDING 1건 유지 |
|
||||||
|
| request_blocked_on_awarded | 낙찰(AWARDED)건에 요청 | 거부 + 미기록 |
|
||||||
|
| request_forbidden_other_supplier | 남의 공급사 세션에 요청 | 거부 + 미기록 |
|
||||||
|
| cancel_sets_canceled_and_allows_rerequest | 요청 후 `DELETE` 철회 → 재요청 | CANCELED → 재요청 시 PENDING |
|
||||||
|
| cancel_requires_pending | 대기 요청 없는데 철회 | 거부 |
|
||||||
|
|
||||||
|
### negodata — 심사(승인/반려) (`negodata/backend/tests/test_renegotiation.py`, 서비스 단위)
|
||||||
|
승인이 호출하는 `regenerate_quotation`(견적 풀체인)은 스텁으로 격리하고 #15 고유 계약만 검증.
|
||||||
|
|
||||||
|
| 테스트 | 플로우 | 기대 |
|
||||||
|
|---|---|---|
|
||||||
|
| list_scoped_to_company | 내 회사·남의 회사 요청 각 1건 → 목록 | 내 회사 건만(total=1) |
|
||||||
|
| list_filters_by_status | 대기·반려 각 1건 → status=1 필터 | 대기 건만 |
|
||||||
|
| approve_transitions_and_persists | 대기건 승인(재생성 성공 스텁) | APPROVED + next_quotation_id + memo 박제, 요청 공급사 포함 호출 |
|
||||||
|
| approve_includes_extra_suppliers | 승인 시 추가 공급사 지정 | 재생성 공급사 = 요청자 ∪ 추가(중복 제거) |
|
||||||
|
| approve_blocks_when_not_pending | 이미 승인된 건 재승인 | 거부(INVALID) + 재생성 미호출 |
|
||||||
|
| approve_keeps_pending_when_regenerate_fails | 재생성 실패 스텁 | 실패 반환 + 상태 PENDING 유지 |
|
||||||
|
| approve_other_company_not_found | 남의 회사 세션 승인(IDOR) | NOT_FOUND + 재생성 미호출 |
|
||||||
|
| reject_transitions_and_saves_memo | 대기건 반려(사유) | REJECTED + 반려사유 저장 |
|
||||||
|
| reject_blocks_when_not_pending | 이미 반려된 건 재반려 | 거부(INVALID) |
|
||||||
|
|
||||||
|
## 4. 미커버 항목에 대한 권고
|
||||||
|
|
||||||
|
- #14/#16/#17/#20/#23(데이터·집계·엑셀)은 서비스 단위 테스트를 붙일 수 있음 — 필요 시 작성.
|
||||||
|
- #18/#19/#22(라벨·UI)와 #21(스크립트 렌더)은 화면·실협상으로 확인하는 게 맞음.
|
||||||
|
- #15는 "승인 → 실제 다음 라운드 견적이 올바른 상품/카드로 생성되는지"는 스텁으로 끊었으므로, 실 데이터 승인 1회로 최종 확인 필요.
|
||||||
@ -80,6 +80,7 @@ export * from './listCardsParams';
|
|||||||
export * from './listItemsParams';
|
export * from './listItemsParams';
|
||||||
export * from './listNotificationsParams';
|
export * from './listNotificationsParams';
|
||||||
export * from './listQuotationsParams';
|
export * from './listQuotationsParams';
|
||||||
|
export * from './listRequestsParams';
|
||||||
export * from './listSuppliersParams';
|
export * from './listSuppliersParams';
|
||||||
export * from './listUsersParams';
|
export * from './listUsersParams';
|
||||||
export * from './lowestPriceEntry';
|
export * from './lowestPriceEntry';
|
||||||
@ -129,6 +130,16 @@ export * from './quotationSettingDataUpdatedAt';
|
|||||||
export * from './quotationSettingDataUserId';
|
export * from './quotationSettingDataUserId';
|
||||||
export * from './quotationStatus';
|
export * from './quotationStatus';
|
||||||
export * from './quotationType';
|
export * from './quotationType';
|
||||||
|
export * from './renegotiationData';
|
||||||
|
export * from './renegotiationDataBidPrice';
|
||||||
|
export * from './renegotiationDataCloseReason';
|
||||||
|
export * from './renegotiationDataDecidedAt';
|
||||||
|
export * from './renegotiationDataDecidedByName';
|
||||||
|
export * from './renegotiationDataDesiredPrice';
|
||||||
|
export * from './renegotiationDataMemo';
|
||||||
|
export * from './renegotiationDataNextQuotationId';
|
||||||
|
export * from './renegotiationDataTargetPrice';
|
||||||
|
export * from './reqApproveRenegotiation';
|
||||||
export * from './reqAwardQuotation';
|
export * from './reqAwardQuotation';
|
||||||
export * from './reqBulkMapByNames';
|
export * from './reqBulkMapByNames';
|
||||||
export * from './reqCheckCodes';
|
export * from './reqCheckCodes';
|
||||||
@ -183,6 +194,7 @@ export * from './reqCreateSupplierManagerName';
|
|||||||
export * from './reqCreateSupplierTotalRevenue';
|
export * from './reqCreateSupplierTotalRevenue';
|
||||||
export * from './reqLogin';
|
export * from './reqLogin';
|
||||||
export * from './reqRegenerateQuotation';
|
export * from './reqRegenerateQuotation';
|
||||||
|
export * from './reqRejectRenegotiation';
|
||||||
export * from './reqResetSupplierAccountPassword';
|
export * from './reqResetSupplierAccountPassword';
|
||||||
export * from './reqResetSupplierAccountPasswordPassword';
|
export * from './reqResetSupplierAccountPasswordPassword';
|
||||||
export * from './reqUpdateCard';
|
export * from './reqUpdateCard';
|
||||||
@ -344,6 +356,11 @@ export * from './resQuotationStatusMsg';
|
|||||||
export * from './resQuotationStatusQtId';
|
export * from './resQuotationStatusQtId';
|
||||||
export * from './resRefreshToken';
|
export * from './resRefreshToken';
|
||||||
export * from './resRefreshTokenMsg';
|
export * from './resRefreshTokenMsg';
|
||||||
|
export * from './resRenegotiationDecision';
|
||||||
|
export * from './resRenegotiationDecisionMsg';
|
||||||
|
export * from './resRenegotiationDecisionNextQuotationId';
|
||||||
|
export * from './resRenegotiationList';
|
||||||
|
export * from './resRenegotiationListMsg';
|
||||||
export * from './resResetSupplierAccountPassword';
|
export * from './resResetSupplierAccountPassword';
|
||||||
export * from './resResetSupplierAccountPasswordMsg';
|
export * from './resResetSupplierAccountPasswordMsg';
|
||||||
export * from './resResetSupplierAccountPasswordNewPassword';
|
export * from './resResetSupplierAccountPasswordNewPassword';
|
||||||
|
|||||||
22
negodata/front/src/api/generated/model/listRequestsParams.ts
Normal file
22
negodata/front/src/api/generated/model/listRequestsParams.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ListRequestsParams = {
|
||||||
|
/**
|
||||||
|
* 요청 상태(1=심사중 2=승인 3=반려 4=철회). 미지정 시 전체
|
||||||
|
*/
|
||||||
|
status?: number | null;
|
||||||
|
/**
|
||||||
|
* @minimum 1
|
||||||
|
*/
|
||||||
|
page?: number;
|
||||||
|
/**
|
||||||
|
* @minimum 1
|
||||||
|
* @maximum 100
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
@ -17,4 +17,5 @@ export const NotificationType = {
|
|||||||
REGENERATED: 2,
|
REGENERATED: 2,
|
||||||
FAILURE: 3,
|
FAILURE: 3,
|
||||||
CREATED: 4,
|
CREATED: 4,
|
||||||
|
RENEGO_REQUESTED: 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
39
negodata/front/src/api/generated/model/renegotiationData.ts
Normal file
39
negodata/front/src/api/generated/model/renegotiationData.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { RenegotiationDataDesiredPrice } from './renegotiationDataDesiredPrice';
|
||||||
|
import type { RenegotiationDataDecidedAt } from './renegotiationDataDecidedAt';
|
||||||
|
import type { RenegotiationDataDecidedByName } from './renegotiationDataDecidedByName';
|
||||||
|
import type { RenegotiationDataMemo } from './renegotiationDataMemo';
|
||||||
|
import type { RenegotiationDataNextQuotationId } from './renegotiationDataNextQuotationId';
|
||||||
|
import type { RenegotiationDataCloseReason } from './renegotiationDataCloseReason';
|
||||||
|
import type { RenegotiationDataTargetPrice } from './renegotiationDataTargetPrice';
|
||||||
|
import type { RenegotiationDataBidPrice } from './renegotiationDataBidPrice';
|
||||||
|
|
||||||
|
export interface RenegotiationData {
|
||||||
|
session_id?: string;
|
||||||
|
quotation_id?: string;
|
||||||
|
qt_number?: string;
|
||||||
|
qt_round?: number;
|
||||||
|
qt_name?: string;
|
||||||
|
item_id?: string;
|
||||||
|
item_name?: string;
|
||||||
|
supplier_id?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
owner_name?: string;
|
||||||
|
status?: number;
|
||||||
|
reason?: string;
|
||||||
|
desired_price?: RenegotiationDataDesiredPrice;
|
||||||
|
requested_at?: string;
|
||||||
|
decided_at?: RenegotiationDataDecidedAt;
|
||||||
|
decided_by_name?: RenegotiationDataDecidedByName;
|
||||||
|
memo?: RenegotiationDataMemo;
|
||||||
|
next_quotation_id?: RenegotiationDataNextQuotationId;
|
||||||
|
close_reason?: RenegotiationDataCloseReason;
|
||||||
|
target_price?: RenegotiationDataTargetPrice;
|
||||||
|
bid_price?: RenegotiationDataBidPrice;
|
||||||
|
can_act?: boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataBidPrice = number | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataCloseReason = number | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataDecidedAt = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataDecidedByName = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataDesiredPrice = number | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataMemo = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataNextQuotationId = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RenegotiationDataTargetPrice = number | null;
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReqApproveRenegotiation {
|
||||||
|
/** 다음 라운드에 함께 넣을 공급사. 비우면 요청자만 */
|
||||||
|
supplier_ids?: string[];
|
||||||
|
/**
|
||||||
|
* 승인 메모
|
||||||
|
* @maxLength 255
|
||||||
|
*/
|
||||||
|
memo?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReqRejectRenegotiation {
|
||||||
|
/**
|
||||||
|
* 반려 사유 — 공급사에게 그대로 노출된다
|
||||||
|
* @maxLength 255
|
||||||
|
*/
|
||||||
|
memo?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResRenegotiationDecisionMsg } from './resRenegotiationDecisionMsg';
|
||||||
|
import type { ResRenegotiationDecisionNextQuotationId } from './resRenegotiationDecisionNextQuotationId';
|
||||||
|
|
||||||
|
export interface ResRenegotiationDecision {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResRenegotiationDecisionMsg;
|
||||||
|
session_id?: string;
|
||||||
|
status?: number;
|
||||||
|
next_quotation_id?: ResRenegotiationDecisionNextQuotationId;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResRenegotiationDecisionMsg = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResRenegotiationDecisionNextQuotationId = string | null;
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResRenegotiationListMsg } from './resRenegotiationListMsg';
|
||||||
|
import type { RenegotiationData } from './renegotiationData';
|
||||||
|
|
||||||
|
export interface ResRenegotiationList {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResRenegotiationListMsg;
|
||||||
|
requests?: RenegotiationData[];
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResRenegotiationListMsg = string | null;
|
||||||
265
negodata/front/src/api/generated/renegotiation/renegotiation.ts
Normal file
265
negodata/front/src/api/generated/renegotiation/renegotiation.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
DataTag,
|
||||||
|
DefinedInitialDataOptions,
|
||||||
|
DefinedUseQueryResult,
|
||||||
|
MutationFunction,
|
||||||
|
QueryClient,
|
||||||
|
QueryFunction,
|
||||||
|
QueryKey,
|
||||||
|
UndefinedInitialDataOptions,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseMutationResult,
|
||||||
|
UseQueryOptions,
|
||||||
|
UseQueryResult
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HTTPValidationError,
|
||||||
|
ListRequestsParams,
|
||||||
|
ReqApproveRenegotiation,
|
||||||
|
ReqRejectRenegotiation,
|
||||||
|
ResRenegotiationDecision,
|
||||||
|
ResRenegotiationList
|
||||||
|
} from '.././model';
|
||||||
|
|
||||||
|
import { customFetch } from '../../mutator/custom-fetch';
|
||||||
|
|
||||||
|
|
||||||
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 재협상 요청 현황
|
||||||
|
*/
|
||||||
|
export const listRequests = (
|
||||||
|
params?: ListRequestsParams,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResRenegotiationList>(
|
||||||
|
{url: `/v1/renegotiation/list`, method: 'GET',
|
||||||
|
params, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListRequestsQueryKey = (params?: ListRequestsParams,) => {
|
||||||
|
return [
|
||||||
|
`/v1/renegotiation/list`, ...(params ? [params]: [])
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListRequestsQueryOptions = <TData = Awaited<ReturnType<typeof listRequests>>, TError = void | HTTPValidationError>(params?: ListRequestsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listRequests>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListRequestsQueryKey(params);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRequests>>> = ({ signal }) => listRequests(params, requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listRequests>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListRequestsQueryResult = NonNullable<Awaited<ReturnType<typeof listRequests>>>
|
||||||
|
export type ListRequestsQueryError = void | HTTPValidationError
|
||||||
|
|
||||||
|
|
||||||
|
export function useListRequests<TData = Awaited<ReturnType<typeof listRequests>>, TError = void | HTTPValidationError>(
|
||||||
|
params: undefined | ListRequestsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listRequests>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listRequests>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listRequests>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListRequests<TData = Awaited<ReturnType<typeof listRequests>>, TError = void | HTTPValidationError>(
|
||||||
|
params?: ListRequestsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listRequests>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listRequests>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listRequests>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListRequests<TData = Awaited<ReturnType<typeof listRequests>>, TError = void | HTTPValidationError>(
|
||||||
|
params?: ListRequestsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listRequests>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary 재협상 요청 현황
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListRequests<TData = Awaited<ReturnType<typeof listRequests>>, TError = void | HTTPValidationError>(
|
||||||
|
params?: ListRequestsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listRequests>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getListRequestsQueryOptions(params,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey ;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 재협상 승인
|
||||||
|
*/
|
||||||
|
export const approve = (
|
||||||
|
sessionId: string,
|
||||||
|
reqApproveRenegotiation: ReqApproveRenegotiation,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResRenegotiationDecision>(
|
||||||
|
{url: `/v1/renegotiation/${sessionId}/approve`, method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', },
|
||||||
|
data: reqApproveRenegotiation, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getApproveMutationOptions = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof approve>>, TError,{sessionId: string;data: ReqApproveRenegotiation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof approve>>, TError,{sessionId: string;data: ReqApproveRenegotiation}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['approve'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof approve>>, {sessionId: string;data: ReqApproveRenegotiation}> = (props) => {
|
||||||
|
const {sessionId,data} = props ?? {};
|
||||||
|
|
||||||
|
return approve(sessionId,data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type ApproveMutationResult = NonNullable<Awaited<ReturnType<typeof approve>>>
|
||||||
|
export type ApproveMutationBody = ReqApproveRenegotiation
|
||||||
|
export type ApproveMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 재협상 승인
|
||||||
|
*/
|
||||||
|
export const useApprove = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof approve>>, TError,{sessionId: string;data: ReqApproveRenegotiation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof approve>>,
|
||||||
|
TError,
|
||||||
|
{sessionId: string;data: ReqApproveRenegotiation},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getApproveMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @summary 재협상 반려
|
||||||
|
*/
|
||||||
|
export const reject = (
|
||||||
|
sessionId: string,
|
||||||
|
reqRejectRenegotiation: ReqRejectRenegotiation,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResRenegotiationDecision>(
|
||||||
|
{url: `/v1/renegotiation/${sessionId}/reject`, method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', },
|
||||||
|
data: reqRejectRenegotiation, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getRejectMutationOptions = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof reject>>, TError,{sessionId: string;data: ReqRejectRenegotiation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof reject>>, TError,{sessionId: string;data: ReqRejectRenegotiation}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['reject'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof reject>>, {sessionId: string;data: ReqRejectRenegotiation}> = (props) => {
|
||||||
|
const {sessionId,data} = props ?? {};
|
||||||
|
|
||||||
|
return reject(sessionId,data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type RejectMutationResult = NonNullable<Awaited<ReturnType<typeof reject>>>
|
||||||
|
export type RejectMutationBody = ReqRejectRenegotiation
|
||||||
|
export type RejectMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 재협상 반려
|
||||||
|
*/
|
||||||
|
export const useReject = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof reject>>, TError,{sessionId: string;data: ReqRejectRenegotiation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof reject>>,
|
||||||
|
TError,
|
||||||
|
{sessionId: string;data: ReqRejectRenegotiation},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getRejectMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ export function Providers({children}: {children: ReactNode}) {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="top-center" />
|
<Toaster richColors position="top-center" expand visibleToasts={9} />
|
||||||
<ConfirmHost />
|
<ConfirmHost />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import DashboardPage from '../pages/dashboard';
|
|||||||
import StatisticsPage from '../pages/statistics';
|
import StatisticsPage from '../pages/statistics';
|
||||||
import ForbiddenPage from '../pages/forbidden';
|
import ForbiddenPage from '../pages/forbidden';
|
||||||
import DevDesignPage from '../pages/dev-design';
|
import DevDesignPage from '../pages/dev-design';
|
||||||
|
import RenegotiationPage from '../pages/renegotiation';
|
||||||
import NotFoundPage from '../pages/not-found';
|
import NotFoundPage from '../pages/not-found';
|
||||||
import ProductsPage from '../pages/products';
|
import ProductsPage from '../pages/products';
|
||||||
import PartnersPage from '../pages/partners';
|
import PartnersPage from '../pages/partners';
|
||||||
@ -83,6 +84,7 @@ export const router = createBrowserRouter([
|
|||||||
{path: 'partners', Component: PartnersPage},
|
{path: 'partners', Component: PartnersPage},
|
||||||
{path: 'quotation', Component: QuotationPage},
|
{path: 'quotation', Component: QuotationPage},
|
||||||
{path: 'cards', Component: CardsPage},
|
{path: 'cards', Component: CardsPage},
|
||||||
|
{path: 'renegotiation', Component: RenegotiationPage},
|
||||||
{path: 'notifications', Component: NotificationsPage},
|
{path: 'notifications', Component: NotificationsPage},
|
||||||
{
|
{
|
||||||
// 최고관리자 전용. 자식 loader 는 부모와 병렬 실행되므로 여기서도 initAuth 를 기다린다(멱등).
|
// 최고관리자 전용. 자식 loader 는 부모와 병렬 실행되므로 여기서도 initAuth 를 기다린다(멱등).
|
||||||
|
|||||||
107
negodata/front/src/components/layout/ActionBanner.tsx
Normal file
107
negodata/front/src/components/layout/ActionBanner.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useListNotifications, useReadOne } from '@/api/generated/notification/notification';
|
||||||
|
import { useListRequests } from '@/api/generated/renegotiation/renegotiation';
|
||||||
|
import { NotificationType } from '@/api/generated/model';
|
||||||
|
import type { NotificationData } from '@/api/generated/model';
|
||||||
|
|
||||||
|
// 하단 우측 토스트로 알릴 알림 = 낙찰·결렬·자동재생성·재협상요청. 단순 생성(CREATED)은 제외.
|
||||||
|
const TOASTED: number[] = [
|
||||||
|
NotificationType.SUCCESS,
|
||||||
|
NotificationType.FAILURE,
|
||||||
|
NotificationType.REGENERATED,
|
||||||
|
NotificationType.RENEGO_REQUESTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
function messageFor(n: NotificationData): string {
|
||||||
|
const d = (n.data ?? {}) as Record<string, unknown>;
|
||||||
|
const qt = String(d.qt_number ?? d.qt_name ?? '견적');
|
||||||
|
const winner = d.winner_name ? ` · 낙찰 ${String(d.winner_name)}` : '';
|
||||||
|
switch (n.type) {
|
||||||
|
case NotificationType.SUCCESS:
|
||||||
|
return `${qt} 낙찰되었습니다${winner}.`;
|
||||||
|
case NotificationType.FAILURE:
|
||||||
|
return `${qt} 결렬로 마감되었습니다.`;
|
||||||
|
case NotificationType.REGENERATED:
|
||||||
|
return `${qt} 다음 라운드가 자동 생성되었습니다.`;
|
||||||
|
case NotificationType.RENEGO_REQUESTED:
|
||||||
|
return `${d.supplier_name ? String(d.supplier_name) + ' · ' : ''}${qt} 재협상 요청이 접수됐습니다.`;
|
||||||
|
default:
|
||||||
|
return `${qt} 알림.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sonner 색상 함수 — 재협상요청=경고(앰버), 낙찰=성공, 결렬=에러, 자동마감=정보.
|
||||||
|
function fireOf(type?: number) {
|
||||||
|
if (type === NotificationType.SUCCESS) return toast.success;
|
||||||
|
if (type === NotificationType.FAILURE) return toast.error;
|
||||||
|
if (type === NotificationType.RENEGO_REQUESTED) return toast.warning;
|
||||||
|
return toast.info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 처리(레이아웃 상주, 화면 요소는 토스트로만).
|
||||||
|
// 새로 도착한 알림만 하단 우측 토스트로 띄우고, [보기]/[X] 로 읽음 처리(readOne API — 알림함과 동일).
|
||||||
|
// 읽으면 서버 read_at 이 박혀서 다시 뜨지 않고, 벨 배지·인박스 수도 함께 줄어든다.
|
||||||
|
export function ActionBanner() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const readOne = useReadOne();
|
||||||
|
|
||||||
|
const { data: notif } = useListNotifications(
|
||||||
|
{ size: 20 },
|
||||||
|
{ query: { refetchInterval: 30_000, staleTime: 10_000 } },
|
||||||
|
);
|
||||||
|
// 재협상 요청 토스트는 '아직 완료 안 된(대기중)' 것만 — 내가 처리 가능한(can_act) 대기 세션 집합.
|
||||||
|
const { data: renego } = useListRequests(
|
||||||
|
{ status: 1, page: 1, size: 50 },
|
||||||
|
{ query: { refetchInterval: 30_000, staleTime: 10_000 } },
|
||||||
|
);
|
||||||
|
const pendingSessions = new Set(
|
||||||
|
(renego?.requests ?? []).filter((r) => r.can_act !== false).map((r) => r.session_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 읽음 처리(서버) 후 알림 목록 무효화 → 벨 배지·인박스 수가 함께 줄어든다.
|
||||||
|
const markRead = (id: string) => {
|
||||||
|
readOne.mutate(
|
||||||
|
{ notificationId: id },
|
||||||
|
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'], exact: false }) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 새로 도착한 건만 토스트(첫 로드 시점의 안읽음 백로그는 벨/인박스가 담당 — 토스트 폭탄 방지).
|
||||||
|
const seen = useRef<Set<string> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const rows = (notif?.notifications ?? []).filter((n) => !n.read_at && TOASTED.includes(n.type));
|
||||||
|
if (seen.current === null) {
|
||||||
|
seen.current = new Set(rows.map((n) => n.notification_id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const n of rows) {
|
||||||
|
if (seen.current.has(n.notification_id)) continue;
|
||||||
|
const isRenego = n.type === NotificationType.RENEGO_REQUESTED;
|
||||||
|
// 재협상 요청은 아직 대기중(미완료)인 세션만 토스트 — 이미 승인/반려된 건 건너뛴다.
|
||||||
|
if (isRenego && !pendingSessions.has(n.ref_session_id ?? '')) continue;
|
||||||
|
seen.current.add(n.notification_id);
|
||||||
|
const target = isRenego ? '/renegotiation' : n.ref_qt_id ? `/quotation?detail=${n.ref_qt_id}` : '/notifications';
|
||||||
|
fireOf(n.type)(messageFor(n), {
|
||||||
|
id: n.notification_id,
|
||||||
|
position: 'bottom-right',
|
||||||
|
duration: Infinity, // 자동으로 안 사라지고, 읽음/닫기로만 사라진다.
|
||||||
|
closeButton: true, // X = 닫기 = 읽음 처리(onDismiss).
|
||||||
|
onDismiss: () => markRead(n.notification_id),
|
||||||
|
action: {
|
||||||
|
label: isRenego ? '확인하러 가기' : '보기',
|
||||||
|
onClick: () => {
|
||||||
|
markRead(n.notification_id); // 알림함과 동일하게 읽음 API 를 찌른다.
|
||||||
|
toast.dismiss(n.notification_id);
|
||||||
|
navigate(target);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [notif]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
|||||||
PARTNERS: '/partners',
|
PARTNERS: '/partners',
|
||||||
QUOTATION: '/quotation',
|
QUOTATION: '/quotation',
|
||||||
CARDS: '/cards',
|
CARDS: '/cards',
|
||||||
|
RENEGOTIATION: '/renegotiation',
|
||||||
MEMBERS: '/members',
|
MEMBERS: '/members',
|
||||||
SETTINGS: '/settings',
|
SETTINGS: '/settings',
|
||||||
DESIGN: '/dev/design',
|
DESIGN: '/dev/design',
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { NotificationBell } from './NotificationBell';
|
import { NotificationBell } from './NotificationBell';
|
||||||
|
import { ActionBanner } from './ActionBanner';
|
||||||
import { GUIDE_TABS, TAB_LABEL, type GuideTab } from '@/features/onboarding/OnboardingGuideModal';
|
import { GUIDE_TABS, TAB_LABEL, type GuideTab } from '@/features/onboarding/OnboardingGuideModal';
|
||||||
import { SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab } from '@/features/settings/SettingsView';
|
import { SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab } from '@/features/settings/SettingsView';
|
||||||
import {
|
import {
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
Building,
|
Building,
|
||||||
Palette,
|
Palette,
|
||||||
|
RefreshCw,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
@ -60,6 +62,7 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
|
|||||||
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
|
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
|
||||||
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
|
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
|
||||||
{ type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' },
|
{ type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' },
|
||||||
|
{ type: 'RENEGOTIATION', label: '재협상 요청', icon: RefreshCw, id: 'sidebar-renegotiation' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -93,6 +96,7 @@ const pageLabelMap: Record<PageType, string> = {
|
|||||||
PARTNERS: '협력사관리',
|
PARTNERS: '협력사관리',
|
||||||
QUOTATION: '견적관리',
|
QUOTATION: '견적관리',
|
||||||
CARDS: '협상카드관리',
|
CARDS: '협상카드관리',
|
||||||
|
RENEGOTIATION: '재협상 요청',
|
||||||
MEMBERS: '회원관리',
|
MEMBERS: '회원관리',
|
||||||
SETTINGS: '회사 설정',
|
SETTINGS: '회사 설정',
|
||||||
DESIGN: '디자인 시스템',
|
DESIGN: '디자인 시스템',
|
||||||
@ -333,12 +337,15 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
|||||||
<div className="max-w-7xl mx-auto space-y-6">{children}</div>
|
<div className="max-w-7xl mx-auto space-y-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Compact Admin footer info */}
|
{/* Compact Admin footer info — 액션 배너가 뜨면 가려지지 않게 여백을 준다 */}
|
||||||
<footer className="h-10 border-t border-border/60 bg-card flex items-center justify-between px-4 md:px-8">
|
<footer className="h-10 border-t border-border/60 bg-card flex items-center justify-between px-4 md:px-8">
|
||||||
<Typography variant="mono">Copyright © O2O Inc. All rights reserved</Typography>
|
<Typography variant="mono">Copyright © O2O Inc. All rights reserved</Typography>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 처리해야 사라지는 알림(재협상 심사 대기 등) — 하단 고정 */}
|
||||||
|
<ActionBanner />
|
||||||
|
|
||||||
<CommandMenu
|
<CommandMenu
|
||||||
open={isCmdOpen}
|
open={isCmdOpen}
|
||||||
onOpenChange={setIsCmdOpen}
|
onOpenChange={setIsCmdOpen}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useForm, Controller } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Descendant } from 'slate';
|
import type { Descendant } from 'slate';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore, canManage } from '@/stores/auth';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -120,7 +120,7 @@ export function CardFormSheet({
|
|||||||
|
|
||||||
// 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙).
|
// 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙).
|
||||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
|
||||||
const canMutate =
|
const canMutate =
|
||||||
mode === 'create' || (!!card && !card.isShared && (card.userId === myUserId || isSuperAdmin));
|
mode === 'create' || (!!card && !card.isShared && (card.userId === myUserId || isSuperAdmin));
|
||||||
const mutateBlockReason = card?.isShared
|
const mutateBlockReason = card?.isShared
|
||||||
|
|||||||
@ -211,8 +211,8 @@ const FAQS: { q: string; a: string }[] = [
|
|||||||
a: '견적 생성의 카드 선택에서 성공률(카드를 쓴 협상 중 타결된 비율) 높은 순으로 정렬되고, 상위 3개에는 순위 배지가 붙어요. 아직 쓰인 적 없는 카드는 표본이 없어 뒤로 밀려요.',
|
a: '견적 생성의 카드 선택에서 성공률(카드를 쓴 협상 중 타결된 비율) 높은 순으로 정렬되고, 상위 3개에는 순위 배지가 붙어요. 아직 쓰인 적 없는 카드는 표본이 없어 뒤로 밀려요.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: '화면에 나오는 용어나 로고를 우리 회사 것으로 바꾸려면?',
|
q: '협력사가 재협상을 요청하면 어디서 처리하나요?',
|
||||||
a: '최고관리자 계정으로 회사 설정에 들어가면 서비스명·로고·색상(브랜딩), 화면 용어(라벨), 추가로 입력받을 항목(커스텀 필드), 감출 항목을 바꿀 수 있어요. 설정을 JSON으로 내보내고 불러올 수도 있어요.',
|
a: '낙찰 없이 개찰(결렬)로 닫힌 건에 한해, 그 견적 마지막 라운드에 참여했던 협력사가 포털에서 재협상을 요청할 수 있어요. (낙찰된 건은 제외이고, 협상을 거부했거나 미참여였던 협력사도 조건이 바뀌면 요청할 수 있어요.) 요청은 「재협상」 화면에서 현황을 보고 승인 또는 반려해요. 승인하면 그 협력사를 포함해 재협상 견적이 바로 생성되고(초청메일은 견적 상세에서 수동 발송), 반려하면 사유가 협력사에게 그대로 보여요.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { PhoneInput } from '@/components/ui/phone-input';
|
import { PhoneInput } from '@/components/ui/phone-input';
|
||||||
import { Sheet } from '@/components/ui/sheet';
|
import { Sheet } from '@/components/ui/sheet';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore, canManage } from '@/stores/auth';
|
||||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||||
@ -85,7 +85,7 @@ export function PartnerFormSheet({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
||||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
|
||||||
|
|
||||||
// 회사 협력사 커스텀필드(정의=companies.settings.supplier_fields, 값=suppliers.custom)
|
// 회사 협력사 커스텀필드(정의=companies.settings.supplier_fields, 값=suppliers.custom)
|
||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
|
|||||||
@ -6,8 +6,9 @@ import type { Partner } from '../types';
|
|||||||
|
|
||||||
type PartnerTableProps = {
|
type PartnerTableProps = {
|
||||||
data: Partner[];
|
data: Partner[];
|
||||||
selectedIds: string[];
|
/** 미전달 시 선택(체크박스) 컬럼 자체를 숨긴다 — 일괄삭제 권한 없는 계정용 */
|
||||||
onSelectionChange: (ids: string[]) => void;
|
selectedIds?: string[];
|
||||||
|
onSelectionChange?: (ids: string[]) => void;
|
||||||
onRowClick: (part: Partner) => void;
|
onRowClick: (part: Partner) => void;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -37,7 +38,7 @@ export function PartnerTable({
|
|||||||
data={data}
|
data={data}
|
||||||
rowKey={(part) => part.supplier_id}
|
rowKey={(part) => part.supplier_id}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
selection={{ selectedKeys: selectedIds, onSelectionChange }}
|
selection={selectedIds && onSelectionChange ? { selectedKeys: selectedIds, onSelectionChange } : undefined}
|
||||||
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
|
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Sheet } from '@/components/ui/sheet';
|
import { Sheet } from '@/components/ui/sheet';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore, canManage } from '@/stores/auth';
|
||||||
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||||
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||||
import { ItemSuppliersManager } from './ItemSuppliersManager';
|
import { ItemSuppliersManager } from './ItemSuppliersManager';
|
||||||
@ -145,7 +145,7 @@ export function ProductFormSheet({
|
|||||||
|
|
||||||
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
||||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
|
||||||
const canManageOwn = !!product && (product.user_id === myUserId || isSuperAdmin);
|
const canManageOwn = !!product && (product.user_id === myUserId || isSuperAdmin);
|
||||||
// 저장 가능 여부 — 신규는 항상, 수정은 소유자/관리자만.
|
// 저장 가능 여부 — 신규는 항상, 수정은 소유자/관리자만.
|
||||||
const canSave = mode === 'create' || canManageOwn;
|
const canSave = mode === 'create' || canManageOwn;
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
|||||||
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
|
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
|
||||||
import { useQuotationChain } from '../../hooks/useQuotationChain';
|
import { useQuotationChain } from '../../hooks/useQuotationChain';
|
||||||
import { useScrollLock } from '@/lib/useScrollLock';
|
import { useScrollLock } from '@/lib/useScrollLock';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore, canManage } from '@/stores/auth';
|
||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import {
|
import {
|
||||||
mapItem,
|
mapItem,
|
||||||
@ -77,7 +77,7 @@ export function QuotationDetailSheet({
|
|||||||
// 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만.
|
// 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만.
|
||||||
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
|
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
|
||||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
|
||||||
const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin);
|
const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin);
|
||||||
const canNotify = canManage; // 초청 메일 발송/재발송
|
const canNotify = canManage; // 초청 메일 발송/재발송
|
||||||
// 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정.
|
// 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정.
|
||||||
|
|||||||
@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { showToast } from '@/lib/notify';
|
||||||
|
import { Sheet } from '@/components/ui/sheet';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { CLOSE_REASON_LABEL, RENEGO_STATUS_LABEL, RenegoStatus, type RenegoRequest } from '../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
request: RenegoRequest;
|
||||||
|
onApprove: (memo: string) => Promise<void>;
|
||||||
|
onReject: (memo: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const won = (v?: number | null) => (v != null ? `₩${Number(v).toLocaleString()}` : '-');
|
||||||
|
|
||||||
|
// 재협상 심사 패널. 담당자가 승인/반려를 결정하는 데 필요한 근거(원 견적 결과 + 요청 내용)를 한 화면에 모은다.
|
||||||
|
export function RenegotiationReviewSheet({ open, request, onApprove, onReject, onClose }: Props) {
|
||||||
|
const [memo, setMemo] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const pending = request.status === RenegoStatus.PENDING;
|
||||||
|
|
||||||
|
// 직전 투찰가 대비 희망가가 얼마나 내려오는지 — 승인 판단의 핵심 숫자.
|
||||||
|
const drop =
|
||||||
|
request.bid_price && request.desired_price
|
||||||
|
? (1 - request.desired_price / request.bid_price) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const run = async (kind: 'approve' | 'reject') => {
|
||||||
|
if (kind === 'reject' && !memo.trim()) {
|
||||||
|
showToast('반려 사유를 입력해 주십시오. 공급사에게 그대로 전달됩니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await (kind === 'approve' ? onApprove(memo.trim()) : onReject(memo.trim()));
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} title="재협상 요청 심사" onClose={onClose}>
|
||||||
|
<div className="mt-6 space-y-4 text-xs">
|
||||||
|
<Row
|
||||||
|
label="공급사"
|
||||||
|
value={request.supplier_name || '-'}
|
||||||
|
strong
|
||||||
|
href={request.supplier_id ? `/partners?detail=${request.supplier_id}` : undefined}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="담당자"
|
||||||
|
value={request.owner_name || '-'}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="상품"
|
||||||
|
value={request.item_name || '-'}
|
||||||
|
href={request.item_id ? `/products?detail=${request.item_id}` : undefined}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="원 견적"
|
||||||
|
value={`${request.qt_number} · ${request.qt_round}차 · 마감사유 ${CLOSE_REASON_LABEL[request.close_reason ?? 0] ?? '-'}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-2">
|
||||||
|
<Typography variant="label">요청 내용</Typography>
|
||||||
|
<Row label="사유" value={request.reason || '-'} />
|
||||||
|
<Row label="희망가" value={won(request.desired_price)} strong />
|
||||||
|
<Row label="직전 투찰가" value={won(request.bid_price)} />
|
||||||
|
<Row label="목표가" value={won(request.target_price)} />
|
||||||
|
{drop != null && (
|
||||||
|
<Typography variant="caption" className={cn('block', drop > 0 ? 'text-emerald-600' : 'text-rose-600')}>
|
||||||
|
{drop > 0
|
||||||
|
? `직전 투찰가보다 ${drop.toFixed(1)}% 낮은 금액을 제시했습니다.`
|
||||||
|
: `직전 투찰가보다 높거나 같은 금액입니다 — 재협상 실익을 확인하십시오.`}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pending ? (
|
||||||
|
request.can_act === false ? (
|
||||||
|
// 남의 견적 요청 — 현황은 보이되 처리(승인/반려)는 견적 작성자 본인·최고관리자만.
|
||||||
|
<Typography variant="caption" className="block rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
이 견적의 담당자(작성자) 또는 최고관리자만 승인·반려할 수 있습니다. 현황 확인만 가능합니다.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography as="label" variant="label">
|
||||||
|
메모 <span className="text-muted-foreground">(반려 시 필수 — 공급사에게 노출)</span>
|
||||||
|
</Typography>
|
||||||
|
<Input value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="예: 목표가와 격차가 커 이번 건은 종료합니다" />
|
||||||
|
</div>
|
||||||
|
<Typography variant="caption" className="block">
|
||||||
|
승인하면 이 공급사를 포함해 <b>재협상 견적이 즉시 생성</b>됩니다. 초청메일은 자동 발송되지 않으니 견적 상세에서 보내십시오.
|
||||||
|
</Typography>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button className="flex-1" onClick={() => run('approve')} disabled={busy}>
|
||||||
|
{busy ? '처리 중…' : '승인하고 재협상 견적 생성'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="flex-1 text-rose-600" onClick={() => run('reject')} disabled={busy}>
|
||||||
|
반려
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-2">
|
||||||
|
<Row label="처리 결과" value={RENEGO_STATUS_LABEL[request.status ?? 0] ?? '-'} strong />
|
||||||
|
{request.decided_by_name && <Row label="처리 담당자" value={request.decided_by_name} />}
|
||||||
|
{request.memo && <Row label="메모" value={request.memo} />}
|
||||||
|
{request.next_quotation_id && (
|
||||||
|
<Link
|
||||||
|
to={`/quotation?detail=${request.next_quotation_id}`}
|
||||||
|
className={cn(typographyVariants({ variant: 'link' }), 'text-xs font-semibold')}
|
||||||
|
>
|
||||||
|
생성된 재협상 견적 보기 ↗
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, strong, href }: { label: string; value: string; strong?: boolean; href?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<Typography as="span" variant="caption" className="shrink-0">{label}</Typography>
|
||||||
|
{href ? (
|
||||||
|
<Link to={href} className={cn(typographyVariants({ variant: 'link' }), 'min-w-0 text-right', strong && 'font-bold')}>
|
||||||
|
{value} ↗
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Typography as="span" variant="small" className={cn('min-w-0 text-right', strong && 'font-bold')}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import { DataTable, type Column } from '@/components/ui/data-table';
|
||||||
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { StatusPill } from '@/features/quotations/components/QuotationDetailSheet/StatusPill';
|
||||||
|
import { CLOSE_REASON_LABEL, RENEGO_STATUS_LABEL, RenegoStatus, type RenegoRequest } from '../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: RenegoRequest[];
|
||||||
|
onRowClick: (req: RenegoRequest) => void;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalCount: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const won = (v?: number | null) => (v != null ? `₩${Number(v).toLocaleString()}` : '-');
|
||||||
|
// ISO(UTC) → 'MM-DD HH:mm'. 목록에선 연도까지 필요 없다.
|
||||||
|
const shortTime = (iso?: string | null) =>
|
||||||
|
iso ? new Date(iso).toLocaleString('sv-SE').slice(5, 16) : '-';
|
||||||
|
|
||||||
|
const statusTone = (status: number) => {
|
||||||
|
if (status === RenegoStatus.PENDING) return 'amber' as const;
|
||||||
|
if (status === RenegoStatus.APPROVED) return 'emerald' as const;
|
||||||
|
if (status === RenegoStatus.REJECTED) return 'rose' as const;
|
||||||
|
return 'zinc' as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RenegotiationTable({ data, onRowClick, page, totalPages, totalCount, pageSize, onPageChange, className }: Props) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
className={className}
|
||||||
|
data={data}
|
||||||
|
rowKey={(r) => r.session_id ?? ''}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
empty="접수된 재협상 요청이 없습니다."
|
||||||
|
footer={
|
||||||
|
<TablePagination
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
label="전체 요청"
|
||||||
|
unit="건"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: '요청일시',
|
||||||
|
align: 'left',
|
||||||
|
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
|
||||||
|
cell: (r) => shortTime(r.requested_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '공급사',
|
||||||
|
align: 'left',
|
||||||
|
mobileHeader: true,
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Typography as="span" variant="small" className="block truncate font-semibold">
|
||||||
|
{r.supplier_name || '-'}
|
||||||
|
</Typography>
|
||||||
|
<Typography as="span" variant="caption" className="block truncate">
|
||||||
|
{r.item_name || '-'}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '견적',
|
||||||
|
align: 'left',
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Typography as="span" variant="small" className="block font-mono">
|
||||||
|
{r.qt_number} <span className="text-muted-foreground">/ {r.qt_round}차</span>
|
||||||
|
</Typography>
|
||||||
|
<Typography as="span" variant="caption" className="block">
|
||||||
|
마감: {CLOSE_REASON_LABEL[r.close_reason ?? 0] ?? '-'}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '담당자',
|
||||||
|
align: 'left',
|
||||||
|
cellClassName: 'whitespace-nowrap',
|
||||||
|
cell: (r) => r.owner_name || '-',
|
||||||
|
},
|
||||||
|
{ header: '요청 사유', align: 'left', cell: (r) => r.reason || '-' },
|
||||||
|
{
|
||||||
|
header: '희망가',
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'font-mono',
|
||||||
|
cell: (r) => won(r.desired_price),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '직전 투찰가',
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'font-mono text-muted-foreground',
|
||||||
|
cell: (r) => won(r.bid_price),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '상태',
|
||||||
|
align: 'center',
|
||||||
|
cell: (r) => (
|
||||||
|
<StatusPill tone={statusTone(r.status ?? 0)}>
|
||||||
|
{RENEGO_STATUS_LABEL[r.status ?? 0] ?? '-'}
|
||||||
|
</StatusPill>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '처리 담당자',
|
||||||
|
align: 'left',
|
||||||
|
cellClassName: 'whitespace-nowrap text-muted-foreground',
|
||||||
|
cell: (r) => r.decided_by_name || '-',
|
||||||
|
},
|
||||||
|
] as Column<RenegoRequest>[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
negodata/front/src/features/renegotiation/types.ts
Normal file
23
negodata/front/src/features/renegotiation/types.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import type { RenegotiationData } from '@/api/generated/model';
|
||||||
|
|
||||||
|
// 재협상 요청 상태(sessions.custom.renegotiation.status). 백엔드 RenegotiationStatus 와 1:1.
|
||||||
|
export const RenegoStatus = { PENDING: 1, APPROVED: 2, REJECTED: 3, CANCELED: 4 } as const;
|
||||||
|
export type RenegoStatusCode = (typeof RenegoStatus)[keyof typeof RenegoStatus];
|
||||||
|
|
||||||
|
export const RENEGO_STATUS_LABEL: Record<number, string> = {
|
||||||
|
[RenegoStatus.PENDING]: '심사 대기',
|
||||||
|
[RenegoStatus.APPROVED]: '승인',
|
||||||
|
[RenegoStatus.REJECTED]: '반려',
|
||||||
|
[RenegoStatus.CANCELED]: '철회',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 마감 사유 — 요청이 걸린 원 견적이 왜 결렬됐는지(담당자 판단 근거).
|
||||||
|
export const CLOSE_REASON_LABEL: Record<number, string> = {
|
||||||
|
1: '낙찰',
|
||||||
|
5: '기준 미달',
|
||||||
|
6: '동가',
|
||||||
|
7: '전원 미응찰',
|
||||||
|
8: '협상거부',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenegoRequest = RenegotiationData;
|
||||||
@ -49,6 +49,20 @@ export function CustomFieldInputs({ fields, state, title = '회사 추가 항목
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : f.type === 'select' ? (
|
||||||
|
<>
|
||||||
|
<Typography as="label" variant="label">{f.label}</Typography>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-xs outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||||
|
value={String(state.values[f.key] ?? '')}
|
||||||
|
onChange={(e) => state.set(f.key, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">선택</option>
|
||||||
|
{(f.options ?? []).map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Typography as="label" variant="label">{f.label}</Typography>
|
<Typography as="label" variant="label">{f.label}</Typography>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload } from 'lucide-react';
|
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -180,30 +180,6 @@ export function SettingsView() {
|
|||||||
placeholder="NegoData (기본값)"
|
placeholder="NegoData (기본값)"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="브랜드 색상" hint="브랜드 마크 색 (hex)">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
aria-label="브랜드 색상 선택"
|
|
||||||
className="size-8 rounded border border-border bg-transparent p-0.5 cursor-pointer"
|
|
||||||
value={draft.branding?.primary_color || '#5E6AD2'}
|
|
||||||
onChange={(e) => setBranding('primary_color', e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
className="w-32"
|
|
||||||
value={draft.branding?.primary_color ?? ''}
|
|
||||||
onChange={(e) => setBranding('primary_color', e.target.value)}
|
|
||||||
placeholder="#5E6AD2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
<Field label="이메일 헤더 문구" hint="협상 초청 메일 상단 브랜드 문구">
|
|
||||||
<Input
|
|
||||||
value={draft.branding?.email_header ?? ''}
|
|
||||||
onChange={(e) => setBranding('email_header', e.target.value)}
|
|
||||||
placeholder="NEGODATA (기본값)"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 로고 이미지 — 업로드(드롭/선택) 또는 URL 직접 입력. 비우면 색상 마크+서비스명 텍스트. */}
|
{/* 로고 이미지 — 업로드(드롭/선택) 또는 URL 직접 입력. 비우면 색상 마크+서비스명 텍스트. */}
|
||||||
@ -430,13 +406,14 @@ function CustomFieldsEditor({
|
|||||||
<TableHead className="p-2 w-52">표시명</TableHead>
|
<TableHead className="p-2 w-52">표시명</TableHead>
|
||||||
<TableHead className="p-2 w-52">키 (영문)</TableHead>
|
<TableHead className="p-2 w-52">키 (영문)</TableHead>
|
||||||
<TableHead className="p-2 w-36">유형</TableHead>
|
<TableHead className="p-2 w-36">유형</TableHead>
|
||||||
|
<TableHead className="p-2">보기 목록 (선택형, 쉼표로 구분)</TableHead>
|
||||||
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="divide-y divide-border bg-background">
|
<TableBody className="divide-y divide-border bg-background">
|
||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="p-6 text-center text-muted-foreground">
|
<TableCell colSpan={5} className="p-6 text-center text-muted-foreground">
|
||||||
추가된 커스텀 필드가 없습니다.
|
추가된 커스텀 필드가 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -476,6 +453,13 @@ function CustomFieldsEditor({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="p-2">
|
||||||
|
<OptionsEditor
|
||||||
|
options={f.options ?? []}
|
||||||
|
disabled={f.type !== 'select'}
|
||||||
|
onChange={(opts) => update(i, { options: opts })}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="p-2 text-center">
|
<TableCell className="p-2 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -504,6 +488,60 @@ function CustomFieldsEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 선택형 필드의 보기 목록 — 칩(태그) 방식. 입력 후 Enter/쉼표로 하나씩 추가, X로 제거, 빈 칸에서 Backspace 로 마지막 제거.
|
||||||
|
function OptionsEditor({
|
||||||
|
options,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
options: string[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (opts: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
|
if (disabled) return <span className="text-[11px] text-muted-foreground">유형이 ‘선택’일 때 사용</span>;
|
||||||
|
|
||||||
|
const add = (raw: string) => {
|
||||||
|
const v = raw.trim();
|
||||||
|
setDraft('');
|
||||||
|
if (v && !options.includes(v)) onChange([...options, v]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-7 flex-wrap items-center gap-1 rounded-md border border-input bg-background px-1.5 py-1">
|
||||||
|
{options.map((o, idx) => (
|
||||||
|
<span key={idx} className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] font-sans">
|
||||||
|
{o}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(options.filter((_, i) => i !== idx))}
|
||||||
|
className="text-muted-foreground hover:text-red-500"
|
||||||
|
title="옵션 삭제"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="h-5 min-w-[70px] flex-1 bg-transparent text-[11px] font-sans outline-none"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => (e.target.value.includes(',') ? add(e.target.value.replace(/,/g, '')) : setDraft(e.target.value))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
add(draft);
|
||||||
|
} else if (e.key === 'Backspace' && !draft && options.length) {
|
||||||
|
onChange(options.slice(0, -1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => add(draft)}
|
||||||
|
placeholder={options.length ? '추가…' : '예: 협력사배송 (엔터로 추가)'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화).
|
// key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화).
|
||||||
function sanitizeKey(raw: string): string {
|
function sanitizeKey(raw: string): string {
|
||||||
return raw.toLowerCase().replace(/[^a-z0-9_]/g, '');
|
return raw.toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
// 회사 커스터마이징 설정(companies.settings JSONB) 문서 타입 + 용어 라벨 카탈로그.
|
// 회사 커스터마이징 설정(companies.settings JSONB) 문서 타입 + 용어 라벨 카탈로그.
|
||||||
// 라벨 키는 여기 한 곳에만 추가한다 — 설정 화면(용어 탭)과 화면 배선(useLabel)이 같은 카탈로그를 읽는다.
|
// 라벨 키는 여기 한 곳에만 추가한다 — 설정 화면(용어 탭)과 화면 배선(useLabel)이 같은 카탈로그를 읽는다.
|
||||||
|
|
||||||
export type CustomFieldType = 'text' | 'number' | 'boolean';
|
export type CustomFieldType = 'text' | 'number' | 'boolean' | 'select';
|
||||||
|
|
||||||
export type CustomFieldDef = {
|
export type CustomFieldDef = {
|
||||||
key: string; // custom JSONB 의 키 (영문 snake_case)
|
key: string; // custom JSONB 의 키 (영문 snake_case)
|
||||||
label: string; // 화면 표시명
|
label: string; // 화면 표시명
|
||||||
type: CustomFieldType;
|
type: CustomFieldType;
|
||||||
|
options?: string[]; // type='select' 일 때 고를 보기 목록
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompanySettings = {
|
export type CompanySettings = {
|
||||||
@ -130,4 +131,5 @@ export const CUSTOM_FIELD_TYPE_LABEL: Record<CustomFieldType, string> = {
|
|||||||
text: '텍스트',
|
text: '텍스트',
|
||||||
number: '숫자',
|
number: '숫자',
|
||||||
boolean: '예/아니오',
|
boolean: '예/아니오',
|
||||||
|
select: '선택',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react';
|
import { CheckCircle2, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2, Handshake } from 'lucide-react';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -295,7 +295,7 @@ function render(n: NotificationData): {
|
|||||||
case NotificationType.SUCCESS:
|
case NotificationType.SUCCESS:
|
||||||
// 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다.
|
// 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다.
|
||||||
return {
|
return {
|
||||||
icon: <Trophy size={15} />,
|
icon: <CheckCircle2 size={15} />,
|
||||||
pill: d.manual ? '낙찰 · 직접' : '낙찰',
|
pill: d.manual ? '낙찰 · 직접' : '낙찰',
|
||||||
pillCls: PILL_TONE.emerald,
|
pillCls: PILL_TONE.emerald,
|
||||||
name,
|
name,
|
||||||
@ -325,6 +325,18 @@ function render(n: NotificationData): {
|
|||||||
}`,
|
}`,
|
||||||
number,
|
number,
|
||||||
};
|
};
|
||||||
|
case NotificationType.RENEGO_REQUESTED: {
|
||||||
|
const reason = d.reason ? `사유 ${String(d.reason)}` : '';
|
||||||
|
const want = d.desired_price != null ? `희망가 ${Number(d.desired_price).toLocaleString()}원` : '';
|
||||||
|
return {
|
||||||
|
icon: <Handshake size={15} />,
|
||||||
|
pill: '재협상 요청',
|
||||||
|
pillCls: PILL_TONE.indigo,
|
||||||
|
name: (d.supplier_name as string) || name,
|
||||||
|
detail: [reason, want].filter(Boolean).join(' · ') || '공급사가 재협상을 요청했습니다',
|
||||||
|
number,
|
||||||
|
};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { icon: <Bell size={15} />, pill: '알림', pillCls: PILL_TONE.muted, name, detail: '', number };
|
return { icon: <Bell size={15} />, pill: '알림', pillCls: PILL_TONE.muted, name, detail: '', number };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { PageContainer } from '@/components/layout/PageContainer';
|
|||||||
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore, canManage } from '@/stores/auth';
|
||||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
import { useServerList } from '@/lib/useServerList';
|
import { useServerList } from '@/lib/useServerList';
|
||||||
import { usePartners } from '@/features/partners/hooks/usePartners';
|
import { usePartners } from '@/features/partners/hooks/usePartners';
|
||||||
@ -40,7 +40,7 @@ export default function PartnersPage() {
|
|||||||
const isFormOpen = overlay.has('new') || !!editing;
|
const isFormOpen = overlay.has('new') || !!editing;
|
||||||
|
|
||||||
// 협력사 삭제는 최고관리자 전용(단건 삭제와 동일 규칙) — 일괄삭제 버튼도 최고관리자에게만 노출.
|
// 협력사 삭제는 최고관리자 전용(단건 삭제와 동일 규칙) — 일괄삭제 버튼도 최고관리자에게만 노출.
|
||||||
const isSuperAdmin = useAuthStore((st) => st.user?.role === '최고관리자');
|
const isSuperAdmin = useAuthStore((st) => canManage(st.user?.role));
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
@ -133,8 +133,8 @@ export default function PartnersPage() {
|
|||||||
<PartnerTable
|
<PartnerTable
|
||||||
className="rounded-none border-0"
|
className="rounded-none border-0"
|
||||||
data={partners}
|
data={partners}
|
||||||
selectedIds={selectedIds}
|
selectedIds={isSuperAdmin ? selectedIds : undefined}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={isSuperAdmin ? setSelectedIds : undefined}
|
||||||
onRowClick={openEdit}
|
onRowClick={openEdit}
|
||||||
page={list.page}
|
page={list.page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
|
|||||||
113
negodata/front/src/pages/renegotiation.tsx
Normal file
113
negodata/front/src/pages/renegotiation.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { showToast } from '@/lib/notify';
|
||||||
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
|
import { useServerList } from '@/lib/useServerList';
|
||||||
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
|
import { PageToolbar } from '@/components/layout/PageToolbar';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useListRequests, approve, reject, getListRequestsQueryKey } from '@/api/generated/renegotiation/renegotiation';
|
||||||
|
import { RenegotiationTable } from '@/features/renegotiation/components/RenegotiationTable';
|
||||||
|
import { RenegotiationReviewSheet } from '@/features/renegotiation/components/RenegotiationReviewSheet';
|
||||||
|
import { RenegoStatus, type RenegoRequest } from '@/features/renegotiation/types';
|
||||||
|
|
||||||
|
// 상태 탭 — 담당자가 가장 먼저 할 일(심사 대기)을 기본으로 연다.
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'PENDING', label: '심사 대기', status: RenegoStatus.PENDING },
|
||||||
|
{ id: 'APPROVED', label: '승인', status: RenegoStatus.APPROVED },
|
||||||
|
{ id: 'REJECTED', label: '반려', status: RenegoStatus.REJECTED },
|
||||||
|
{ id: 'ALL', label: '전체', status: undefined },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function RenegotiationPage() {
|
||||||
|
const list = useServerList({ pageSize: 20 });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const activeTab = (list.filters.tab as string) || 'PENDING';
|
||||||
|
const status = TABS.find((t) => t.id === activeTab)?.status;
|
||||||
|
|
||||||
|
const { data } = useListRequests({ status, page: list.page, size: list.pageSize });
|
||||||
|
const requests = (data?.requests ?? []) as RenegoRequest[];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = list.totalPages(total);
|
||||||
|
|
||||||
|
const overlay = useOverlayRouter(['detail']);
|
||||||
|
const detailId = overlay.get('detail');
|
||||||
|
const active = detailId ? requests.find((r) => r.session_id === detailId) ?? null : null;
|
||||||
|
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() });
|
||||||
|
|
||||||
|
const handleApprove = async (memo: string) => {
|
||||||
|
if (!active?.session_id) return;
|
||||||
|
const res = await approve(active.session_id, { memo, supplier_ids: [] });
|
||||||
|
if (res.result?.success === false) {
|
||||||
|
showToast(res.msg || '승인에 실패했습니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('승인했습니다. 다음 차수 견적이 생성되었습니다 — 초청메일을 발송해 주십시오.', 'success');
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (memo: string) => {
|
||||||
|
if (!active?.session_id) return;
|
||||||
|
const res = await reject(active.session_id, { memo });
|
||||||
|
if (res.result?.success === false) {
|
||||||
|
showToast(res.msg || '반려에 실패했습니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('반려했습니다. 사유가 공급사에게 전달됩니다.', 'success');
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<PageToolbar className="rounded-none border-0 border-b border-border">
|
||||||
|
<Typography variant="caption">
|
||||||
|
협력사가 결렬(개찰) 건에 대해 다시 협상하자고 요청한 목록입니다. 승인하면 다음 차수 견적이 생성됩니다.
|
||||||
|
</Typography>
|
||||||
|
</PageToolbar>
|
||||||
|
|
||||||
|
<div className="flex border-b border-border px-3">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => list.setFilter('tab', tab.id)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer border-b-2 px-5 py-2 text-xs font-bold tracking-tight transition-all',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.id === 'PENDING' && status === RenegoStatus.PENDING && total > 0 && ` (${total})`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RenegotiationTable
|
||||||
|
className="rounded-none border-0"
|
||||||
|
data={requests}
|
||||||
|
onRowClick={(r) => r.session_id && overlay.open('detail', r.session_id)}
|
||||||
|
page={list.page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={total}
|
||||||
|
pageSize={list.pageSize}
|
||||||
|
onPageChange={list.setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<RenegotiationReviewSheet
|
||||||
|
key={active.session_id}
|
||||||
|
open
|
||||||
|
request={active}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onReject={handleReject}
|
||||||
|
onClose={overlay.close}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,13 +3,14 @@ import { PageContainer } from '@/components/layout/PageContainer';
|
|||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAuth } from '@/features/auth/useAuth';
|
import { useAuth } from '@/features/auth/useAuth';
|
||||||
|
import { canManage } from '@/stores/auth';
|
||||||
import { StatisticsView, useStatistics, type Scope } from '@/features/statistics';
|
import { StatisticsView, useStatistics, type Scope } from '@/features/statistics';
|
||||||
|
|
||||||
// 통계(성과 분석). 회사 전체 스코프는 최고관리자만, 일반 사용자는 '내 견적'만 본다.
|
// 통계(성과 분석). 회사 전체 스코프는 최고관리자만, 일반 사용자는 '내 견적'만 본다.
|
||||||
// 데이터는 백엔드 파생 집계(/v1/statistics/summary) — 최근 6개월 창.
|
// 데이터는 백엔드 파생 집계(/v1/statistics/summary) — 최근 6개월 창.
|
||||||
export default function StatisticsPage() {
|
export default function StatisticsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isOwner = user?.role === '최고관리자';
|
const isOwner = canManage(user?.role);
|
||||||
const [scope, setScope] = useState<Scope>('company');
|
const [scope, setScope] = useState<Scope>('company');
|
||||||
const activeScope: Scope = isOwner ? scope : 'mine';
|
const activeScope: Scope = isOwner ? scope : 'mine';
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export const useAuthStore = create<AuthStore>((set) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const isLoggedIn = () => useAuthStore.getState().user !== null;
|
export const isLoggedIn = () => useAuthStore.getState().user !== null;
|
||||||
|
// 레벨2 이상(최고관리자·개발자) — 변경 액션 게이트. 백엔드 authz(role >= OWNER)·메뉴 노출(Layout ownerOnly)과 같은 기준.
|
||||||
|
export const canManage = (role?: UserRole | null) => role === '최고관리자' || role === '개발자';
|
||||||
export const hasRole = (...roles: UserRole[]) => {
|
export const hasRole = (...roles: UserRole[]) => {
|
||||||
const u = useAuthStore.getState().user;
|
const u = useAuthStore.getState().user;
|
||||||
return u ? roles.includes(u.role) : false;
|
return u ? roles.includes(u.role) : false;
|
||||||
|
|||||||
@ -37,4 +37,4 @@ export interface NegotiationCard {
|
|||||||
usedCount: number; // 카드 사용 세션 수(표본)
|
usedCount: number; // 카드 사용 세션 수(표본)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user