Merge branch 'feature/negodata' into feature/landing
This commit is contained in:
commit
e67e713270
@ -58,6 +58,21 @@ class suppliers(MAIN_BASE):
|
|||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
|
class companies(MAIN_BASE):
|
||||||
|
# company.companies (고객사). 공급사 포털 브랜딩(settings.branding) 조회 전용 미러.
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.PARTNER.value
|
||||||
|
|
||||||
|
__tablename__ = "companies"
|
||||||
|
__table_args__ = {"schema": "company"}
|
||||||
|
|
||||||
|
company_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 회사 식별자(PK)
|
||||||
|
name = Column(String(100), nullable=False) # 회사명
|
||||||
|
settings = Column(JSONB, nullable=True) # 회사별 커스터마이징(branding/labels 등, negodata 소유)
|
||||||
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
class items(MAIN_BASE):
|
class items(MAIN_BASE):
|
||||||
# partner.items (상품).
|
# partner.items (상품).
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -123,6 +138,7 @@ class sessions(MAIN_BASE):
|
|||||||
reject_reason = Column(String(255), nullable=True) # 거절 사유
|
reject_reason = Column(String(255), nullable=True) # 거절 사유
|
||||||
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
|
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
|
||||||
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
|
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
|
||||||
|
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
|
||||||
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)
|
||||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
@ -183,6 +199,20 @@ class quotation_settings(MAIN_BASE):
|
|||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
|
class nego_cards(MAIN_BASE):
|
||||||
|
# card.nego_cards (협상카드). backend 는 번호→UUID 변환(chats.card_id 저장)만 위해 최소 컬럼 미러.
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.NEGOTIATION.value # 같은 negosium_db — chats 와 동일 세션풀로 조회
|
||||||
|
|
||||||
|
__tablename__ = "nego_cards"
|
||||||
|
__table_args__ = {"schema": "card"}
|
||||||
|
|
||||||
|
nego_card_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
||||||
|
number = Column(String(10), nullable=True) # 카드 번호(agent turn.card_id 와 매칭)
|
||||||
|
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
|
||||||
|
|
||||||
class chats(MAIN_BASE):
|
class chats(MAIN_BASE):
|
||||||
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
|
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update
|
|||||||
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 chats, items, sessions
|
from common.database.model.models import chats, items, sessions, nego_cards
|
||||||
from common.enums import ErrorType, SessionStatus
|
from common.enums import ErrorType, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
@ -47,6 +47,10 @@ class IChatCRUD(ABC):
|
|||||||
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChatCRUD(IChatCRUD):
|
class ChatCRUD(IChatCRUD):
|
||||||
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
||||||
@ -112,6 +116,18 @@ class ChatCRUD(IChatCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
|
||||||
|
# 협상카드 번호(agent turn.card_id) → nego_card_id(UUID). 없으면 None. 카드 사용 로그(chats.card_id) 저장용.
|
||||||
|
try:
|
||||||
|
query = select(nego_cards.nego_card_id).where(nego_cards.number == number, nego_cards.deleted == False).limit(1) # noqa: E712
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_nego_card_id_by_number({number}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS or not row_list:
|
||||||
|
return None
|
||||||
|
return row_list[0]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return None
|
||||||
|
|
||||||
async def finalize_session(
|
async def finalize_session(
|
||||||
self, cdb: AsyncSession, session_id, status: int,
|
self, cdb: AsyncSession, session_id, status: int,
|
||||||
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
||||||
|
|||||||
@ -41,6 +41,10 @@ class ISessionCRUD(ABC):
|
|||||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SessionCRUD(ISessionCRUD):
|
class SessionCRUD(ISessionCRUD):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -86,6 +90,7 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
items.name,
|
items.name,
|
||||||
items.model_name,
|
items.model_name,
|
||||||
items.manufacturer,
|
items.manufacturer,
|
||||||
|
sessions.custom,
|
||||||
)
|
)
|
||||||
.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)
|
||||||
@ -173,3 +178,16 @@ class SessionCRUD(ISessionCRUD):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
||||||
|
# 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드).
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(sessions)
|
||||||
|
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
|
||||||
|
.values(custom=custom)
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from sqlalchemy import delete, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from 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 supplier_user_tokens, supplier_users, suppliers
|
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers, companies
|
||||||
from common.enums import ErrorType, TokenType
|
from common.enums import ErrorType, TokenType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -28,6 +28,10 @@ class IUserCRUD(ABC):
|
|||||||
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
|
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
@ -117,6 +121,25 @@ class UserCRUD(IUserCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
|
||||||
|
"""공급사 소속 회사 설정(companies.settings) 전체. 브랜딩·협상완료 필드 등이 들어있다. 미설정이면 빈 dict."""
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(companies.settings)
|
||||||
|
.join(suppliers, suppliers.company_id == companies.company_id)
|
||||||
|
.where(suppliers.supplier_id == supplier_id, suppliers.deleted == False, companies.deleted == False) # noqa: E712
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_company_settings(supplier_id:{supplier_id}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, {}
|
||||||
|
settings = row_list[0] if row_list else None
|
||||||
|
return ErrorType.SUCCESS, settings or {}
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, {}
|
||||||
|
|
||||||
|
|
||||||
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
|
|||||||
@ -48,6 +48,8 @@ class Res_Me(Res_WebPacketProtocol):
|
|||||||
supplier_id: str = Field("", description="소속 공급사 uuid")
|
supplier_id: str = Field("", description="소속 공급사 uuid")
|
||||||
supplier_name: str = Field("", description="공급사명")
|
supplier_name: str = Field("", description="공급사명")
|
||||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||||
|
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
|
||||||
|
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력")
|
||||||
|
|
||||||
|
|
||||||
class Res_Logout(Res_WebPacketProtocol):
|
class Res_Logout(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -77,6 +77,7 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
|||||||
item_min_order_quantity: str = Field("", description="최소 주문 수량")
|
item_min_order_quantity: str = Field("", description="최소 주문 수량")
|
||||||
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
||||||
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
||||||
|
|
||||||
|
|
||||||
# 대화 히스토리(재진입 복원)
|
# 대화 히스토리(재진입 복원)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class ListItem(WebPacketProtocol):
|
|||||||
item_name: str = Field("", description="상품명")
|
item_name: str = Field("", description="상품명")
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
class Res_SessionList(Res_WebPacketProtocol):
|
class Res_SessionList(Res_WebPacketProtocol):
|
||||||
@ -33,3 +34,11 @@ class Req_Reject(WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_Reject(Res_WebPacketProtocol):
|
class Res_Reject(Res_WebPacketProtocol):
|
||||||
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class Req_ExtraInfo(WebPacketProtocol):
|
||||||
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값 {key: value} (회사 정의 session_fields 대로)")
|
||||||
|
|
||||||
|
|
||||||
|
class Res_ExtraInfo(Res_WebPacketProtocol):
|
||||||
|
session_id: str = Field("", description="부가정보 저장된 세션 uuid")
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials
|
|||||||
from common.models.gmodel import UserInfo
|
from 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_Reject, Res_Participate, Res_Reject, Res_SessionList
|
from .protocol import Req_ExtraInfo, Req_Reject, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
@ -61,3 +61,19 @@ async def reject(
|
|||||||
service: NegotiationService = Depends(),
|
service: NegotiationService = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
path="/sessions/{session_id}/extra-info",
|
||||||
|
response_model=Res_ExtraInfo,
|
||||||
|
summary="협상완료 부가정보 저장",
|
||||||
|
description="협상 타결(완료) 세션에 부가정보(표준납기/MOQ/발주배수/배송유형 등, 회사 정의 session_fields)를 저장한다. 본인 공급사의 완료 세션만 허용.",
|
||||||
|
)
|
||||||
|
async def save_extra_info(
|
||||||
|
session_id: str = Path(description="대상 협상 세션 uuid"),
|
||||||
|
req: Req_ExtraInfo = ...,
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: NegotiationService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req))
|
||||||
|
|||||||
@ -250,6 +250,15 @@ class AuthService:
|
|||||||
res.supplier_id = info.supplier_id
|
res.supplier_id = info.supplier_id
|
||||||
res.supplier_name = info.supplier_name
|
res.supplier_name = info.supplier_name
|
||||||
res.role = info.role
|
res.role = info.role
|
||||||
|
# 소속 회사 설정(companies.settings) — 로고/서비스명(branding) + 협상완료 부가필드(session_fields). 실패해도 기본값.
|
||||||
|
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
suppliers.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_company_settings(s, uuid.UUID(info.supplier_id)),
|
||||||
|
)
|
||||||
|
settings = settings or {}
|
||||||
|
res.branding = settings.get("branding") or {}
|
||||||
|
res.session_fields = settings.get("session_fields") or []
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
|
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
|
||||||
|
|||||||
@ -103,13 +103,18 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None, nego_card_uuid=None) -> chats:
|
||||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
||||||
|
# nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재
|
||||||
|
# → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업)
|
||||||
return chats(
|
return chats(
|
||||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||||
sender=ChatSender.BOT.value,
|
sender=ChatSender.BOT.value,
|
||||||
target_price=int(sess.target_price or 0),
|
target_price=int(sess.target_price or 0),
|
||||||
indicator_value=turn.indicator_value,
|
indicator_value=turn.indicator_value,
|
||||||
|
card_id=nego_card_uuid,
|
||||||
|
card_type=1 if nego_card_uuid else None, # CardType: 1=nego (wild=2 는 별도)
|
||||||
|
card_used_yn=True if nego_card_uuid else None,
|
||||||
meta={
|
meta={
|
||||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||||
@ -221,6 +226,7 @@ class ChatService:
|
|||||||
res.item_min_order_quantity = item.moq or ""
|
res.item_min_order_quantity = item.moq or ""
|
||||||
res.item_vat_yn = item.vat_yn
|
res.item_vat_yn = item.vat_yn
|
||||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||||
|
res.custom = sess.custom or {}
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# ---- messages -------------------------------------------------------
|
# ---- messages -------------------------------------------------------
|
||||||
@ -371,8 +377,16 @@ class ChatService:
|
|||||||
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
||||||
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
||||||
|
|
||||||
|
# nego 카드 번호(turn.card_id) → UUID 변환. 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다.
|
||||||
|
nego_card_uuid = None
|
||||||
|
if turn.card_id:
|
||||||
|
nego_card_uuid = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)),
|
||||||
|
)
|
||||||
|
|
||||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
|
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, nego_card_uuid=nego_card_uuid)
|
||||||
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from common.database.model.models import sessions
|
|||||||
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||||
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList
|
from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
@ -65,6 +65,7 @@ class NegotiationService:
|
|||||||
item_name=r[6] or "",
|
item_name=r[6] or "",
|
||||||
model_name=r[7] or "",
|
model_name=r[7] or "",
|
||||||
maker_name=r[8] or "",
|
maker_name=r[8] or "",
|
||||||
|
custom=r[9] or {},
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -73,6 +74,53 @@ class NegotiationService:
|
|||||||
res.page_size = page_size
|
res.page_size = page_size
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
async def save_extra_info(self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_ExtraInfo) -> Res_ExtraInfo:
|
||||||
|
"""협상완료(타결) 부가정보 저장. 견적 마감 여부와 무관하게, 본인 공급사의 '협상완료' 세션에만 허용.
|
||||||
|
|
||||||
|
_load_actionable_session 은 견적마감·마감시간을 막으므로(타결 후엔 마감됐을 수 있음) 쓰지 않고 직접 검증한다.
|
||||||
|
"""
|
||||||
|
res = Res_ExtraInfo()
|
||||||
|
|
||||||
|
# 1) 인증
|
||||||
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(session_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 2) 세션 조회 + 소유 검증
|
||||||
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or sess is None:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
if str(sess.supplier_id) != info.supplier_id:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 3) 협상완료(타결) 세션만 부가정보 입력 허용
|
||||||
|
if sess.status != SessionStatus.DONE.value:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 4) 저장(supplier_id 가드 crud)
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.session_crud.update_session_custom(s, session_id, uuid.UUID(info.supplier_id), req.custom or {})],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.session_id = str(session_id)
|
||||||
|
return res
|
||||||
|
|
||||||
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
||||||
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
||||||
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
||||||
|
|||||||
@ -54,6 +54,20 @@ export interface RefreshTokenResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 내 정보 (GET /v1/auth/me) -------------------------------------------
|
// --- 내 정보 (GET /v1/auth/me) -------------------------------------------
|
||||||
|
export interface Branding {
|
||||||
|
service_name?: string
|
||||||
|
logo_url?: string
|
||||||
|
primary_color?: string
|
||||||
|
email_header?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 협상완료 부가정보 필드 정의(companies.settings.session_fields)
|
||||||
|
export interface SessionField {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'number' | 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
result: ApiResult
|
result: ApiResult
|
||||||
su_id: string
|
su_id: string
|
||||||
@ -62,6 +76,8 @@ export interface MeResponse {
|
|||||||
supplier_id: string
|
supplier_id: string
|
||||||
supplier_name: string
|
supplier_name: string
|
||||||
role: number
|
role: number
|
||||||
|
branding?: Branding
|
||||||
|
session_fields?: SessionField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 로그아웃 -------------------------------------------------------------
|
// --- 로그아웃 -------------------------------------------------------------
|
||||||
@ -94,6 +110,8 @@ export interface AuthUser {
|
|||||||
supplierId: string
|
supplierId: string
|
||||||
supplierName: string
|
supplierName: string
|
||||||
role: number
|
role: number
|
||||||
|
branding: Branding
|
||||||
|
sessionFields: SessionField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAuthUser(res: MeResponse): AuthUser {
|
export function toAuthUser(res: MeResponse): AuthUser {
|
||||||
@ -104,5 +122,7 @@ export function toAuthUser(res: MeResponse): AuthUser {
|
|||||||
supplierId: res.supplier_id,
|
supplierId: res.supplier_id,
|
||||||
supplierName: res.supplier_name,
|
supplierName: res.supplier_name,
|
||||||
role: res.role,
|
role: res.role,
|
||||||
|
branding: res.branding ?? {},
|
||||||
|
sessionFields: res.session_fields ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export interface ChatInitResponse {
|
|||||||
item_min_order_quantity?: string
|
item_min_order_quantity?: string
|
||||||
item_vat_yn?: boolean
|
item_vat_yn?: boolean
|
||||||
item_delivery_fee_yn?: boolean
|
item_delivery_fee_yn?: boolean
|
||||||
|
custom?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessagesResponse {
|
export interface ChatMessagesResponse {
|
||||||
@ -98,6 +99,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
|
|||||||
item_delivery_fee_yn:
|
item_delivery_fee_yn:
|
||||||
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
|
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
|
||||||
item_min_order_quantity: r.item_min_order_quantity ?? '',
|
item_min_order_quantity: r.item_min_order_quantity ?? '',
|
||||||
|
custom: r.custom ?? {},
|
||||||
item_lead_time: r.item_lead_time ?? '',
|
item_lead_time: r.item_lead_time ?? '',
|
||||||
item_spec: r.item_spec ?? '',
|
item_spec: r.item_spec ?? '',
|
||||||
quotation_memo: r.quotation_memo ?? '',
|
quotation_memo: r.quotation_memo ?? '',
|
||||||
|
|||||||
@ -2,5 +2,5 @@
|
|||||||
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 } from './negotiation.mutations'
|
export { useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from './negotiation.mutations'
|
||||||
export * from './negotiation.type'
|
export * from './negotiation.type'
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
||||||
import { http } from '@/apis/http'
|
import { http } from '@/apis/http'
|
||||||
import type {
|
import type {
|
||||||
|
ExtraInfoRequest,
|
||||||
|
ExtraInfoResponse,
|
||||||
ParticipateResponse,
|
ParticipateResponse,
|
||||||
RejectRequest,
|
RejectRequest,
|
||||||
RejectResponse,
|
RejectResponse,
|
||||||
@ -31,4 +33,13 @@ export const negotiationApi = {
|
|||||||
)
|
)
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */
|
||||||
|
saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise<ExtraInfoResponse> => {
|
||||||
|
const res = await http.post<ExtraInfoResponse>(
|
||||||
|
`/v1/negotiation/sessions/${sessionId}/extra-info`,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { 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 { RejectRequest } from './negotiation.type'
|
import type { ExtraInfoRequest, RejectRequest } from './negotiation.type'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
|
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
|
||||||
@ -30,3 +30,17 @@ export function useRejectMutation() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 협상완료 부가정보 저장: 성공 시 세션 목록 캐시를 무효화한다.
|
||||||
|
*/
|
||||||
|
export function useSaveExtraInfoMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ sessionId, request }: { sessionId: string; request: ExtraInfoRequest }) =>
|
||||||
|
negotiationApi.saveExtraInfo(sessionId, request),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export interface SessionListItem {
|
|||||||
item_name: string
|
item_name: string
|
||||||
model_name: string
|
model_name: string
|
||||||
maker_name: string
|
maker_name: string
|
||||||
|
custom: Record<string, unknown> // 협상완료 부가정보(sessions.custom). 미입력이면 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionListResponse {
|
export interface SessionListResponse {
|
||||||
@ -65,6 +66,15 @@ export interface SessionListResponse {
|
|||||||
page_size: number
|
page_size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 협상완료 부가정보 (POST /v1/negotiation/sessions/{id}/extra-info) -----
|
||||||
|
export interface ExtraInfoRequest {
|
||||||
|
custom: Record<string, unknown>
|
||||||
|
}
|
||||||
|
export interface ExtraInfoResponse {
|
||||||
|
result: ApiResult
|
||||||
|
session_id: string
|
||||||
|
}
|
||||||
|
|
||||||
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
|
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
|
||||||
export interface ParticipateResponse {
|
export interface ParticipateResponse {
|
||||||
result: ApiResult
|
result: ApiResult
|
||||||
|
|||||||
@ -22,6 +22,9 @@ export interface LogoProps extends Omit<ComponentProps<'div'>, 'children'> {
|
|||||||
size?: LogoSize
|
size?: LogoSize
|
||||||
withText?: boolean
|
withText?: boolean
|
||||||
alt?: string
|
alt?: string
|
||||||
|
/** 회사 브랜딩 오버라이드(companies.settings.branding). 없으면 기본 iMarket Korea. */
|
||||||
|
serviceName?: string
|
||||||
|
logoUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Logo({
|
export function Logo({
|
||||||
@ -29,21 +32,24 @@ export function Logo({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
withText = true,
|
withText = true,
|
||||||
alt = 'iMarket Korea',
|
alt = 'iMarket Korea',
|
||||||
|
serviceName,
|
||||||
|
logoUrl,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: LogoProps) {
|
}: LogoProps) {
|
||||||
const s = sizes[size]
|
const s = sizes[size]
|
||||||
|
const name = serviceName || 'iMarket Korea'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center', s.gap, className)} {...props}>
|
<div className={cn('flex items-center', s.gap, className)} {...props}>
|
||||||
<img
|
<img
|
||||||
src={sources[variant]}
|
src={logoUrl || sources[variant]}
|
||||||
alt={withText ? '' : alt}
|
alt={withText ? '' : (serviceName || alt)}
|
||||||
className={cn('w-auto select-none', s.img)}
|
className={cn('w-auto select-none', s.img)}
|
||||||
/>
|
/>
|
||||||
{withText && (
|
{withText && (
|
||||||
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
|
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
|
||||||
iMarket Korea
|
{name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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'
|
||||||
@ -108,7 +109,12 @@ 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 && <Summary data={message.summary} />}
|
{message.bot_chat_type === 'summaryRSP' && 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}
|
||||||
|
|||||||
@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useMeQuery, useSaveExtraInfoMutation, getApiErrorMessage } from '@/apis'
|
||||||
|
import type { SessionField } from '@/apis/auth/auth.type'
|
||||||
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|
||||||
|
// 협상 타결(Summary) 직후 부가정보 입력 폼. 필드 정의(session_fields)는 회사 설정(/me)에서 온다.
|
||||||
|
// 저장하면 sessions.custom 에 기록되고 negodata 견적상세에 표시된다. 정의가 없으면 렌더하지 않는다.
|
||||||
|
export function ExtraInfoForm() {
|
||||||
|
const sessionId = useChatStore((s) => s.sessionId)
|
||||||
|
const { data: user } = useMeQuery()
|
||||||
|
const fields: SessionField[] = user?.sessionFields ?? []
|
||||||
|
const save = useSaveExtraInfoMutation()
|
||||||
|
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
|
||||||
|
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({})
|
||||||
|
const [inited, setInited] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
// fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존.
|
||||||
|
useEffect(() => {
|
||||||
|
if (inited || fields.length === 0) return
|
||||||
|
const init: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) init[f.key] = existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
|
||||||
|
setValues(init)
|
||||||
|
setInited(true)
|
||||||
|
}, [inited, fields, existing])
|
||||||
|
|
||||||
|
if (fields.length === 0) return null
|
||||||
|
|
||||||
|
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const custom: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) {
|
||||||
|
const v = values[f.key]
|
||||||
|
if (f.type === 'boolean') custom[f.key] = !!v
|
||||||
|
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
|
||||||
|
}
|
||||||
|
save.mutate(
|
||||||
|
{ sessionId, request: { custom } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaved(true)
|
||||||
|
toast.success('부가정보가 저장되었습니다.')
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="size-5 text-brand-600" />
|
||||||
|
<h3 className="text-sm font-bold text-neutral-90">부가정보 입력</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
|
||||||
|
협상이 완료되었습니다. 아래 정보를 입력해 주세요. (저장 후에도 세션 목록에서 수정할 수 있습니다.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
|
||||||
|
{f.type === 'boolean' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!values[f.key]}
|
||||||
|
onClick={() => set(f.key, !values[f.key])}
|
||||||
|
disabled={saved}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||||
|
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={f.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={String(values[f.key] ?? '')}
|
||||||
|
onChange={(e) => set(f.key, e.target.value)}
|
||||||
|
disabled={saved}
|
||||||
|
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:bg-neutral-10"
|
||||||
|
placeholder={f.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={save.isPending || saved}
|
||||||
|
className="mt-4 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saved ? '저장 완료' : save.isPending ? '저장 중…' : '부가정보 저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ const initialState: ChatInitData = {
|
|||||||
item_spec: '',
|
item_spec: '',
|
||||||
quotation_memo: '',
|
quotation_memo: '',
|
||||||
quotation_end_time: '',
|
quotation_end_time: '',
|
||||||
|
custom: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||||
|
|||||||
@ -81,4 +81,5 @@ export type ChatInitData = {
|
|||||||
item_spec: string
|
item_spec: string
|
||||||
quotation_memo: string
|
quotation_memo: string
|
||||||
quotation_end_time: string
|
quotation_end_time: string
|
||||||
|
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
||||||
}
|
}
|
||||||
|
|||||||
115
frontend/src/features/list/components/ExtraInfoPopup.tsx
Normal file
115
frontend/src/features/list/components/ExtraInfoPopup.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Modal } from '@/components'
|
||||||
|
import { useMeQuery } from '@/apis'
|
||||||
|
import type { SessionField } from '@/apis/auth/auth.type'
|
||||||
|
import type { ListItem } from '../types'
|
||||||
|
|
||||||
|
export interface ExtraInfoPopupProps {
|
||||||
|
target: ListItem
|
||||||
|
onClose: () => void
|
||||||
|
/** 부가정보 저장 (custom = {key: value}) */
|
||||||
|
onSubmit: (custom: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 협상완료 부가정보 입력 팝업. 필드 정의(session_fields)는 회사 설정(/me)에서 오고, 기존 입력값(target.custom)으로 프리필.
|
||||||
|
export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProps) {
|
||||||
|
const { data: user } = useMeQuery()
|
||||||
|
const fields: SessionField[] = user?.sessionFields ?? []
|
||||||
|
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
||||||
|
const init: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '')
|
||||||
|
return init
|
||||||
|
})
|
||||||
|
|
||||||
|
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// 빈 값은 제외하고 저장(부분 입력 허용)
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
for (const f of fields) {
|
||||||
|
const v = values[f.key]
|
||||||
|
if (f.type === 'boolean') out[f.key] = !!v
|
||||||
|
else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v
|
||||||
|
}
|
||||||
|
onSubmit(out)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-neutral-90">협상완료 부가정보</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-60">{target.qt_number} · {target.item_name}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-neutral-60">입력할 부가정보 항목이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
fields.map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
|
||||||
|
{f.type === 'boolean' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!values[f.key]}
|
||||||
|
onClick={() => set(f.key, !values[f.key])}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={f.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={String(values[f.key] ?? '')}
|
||||||
|
onChange={(e) => set(f.key, e.target.value)}
|
||||||
|
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
|
||||||
|
placeholder={f.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 border-t border-border p-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={fields.length === 0}
|
||||||
|
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white hover:bg-brand-700 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -9,10 +9,11 @@ interface WorkspaceCardsProps {
|
|||||||
busyId?: string | null
|
busyId?: string | null
|
||||||
onEnter: (item: ListItem) => void
|
onEnter: (item: ListItem) => void
|
||||||
onReject: (item: ListItem) => void
|
onReject: (item: ListItem) => void
|
||||||
|
onExtraInfo: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
||||||
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: WorkspaceCardsProps) {
|
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: 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">
|
||||||
@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }:
|
|||||||
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} />
|
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -38,25 +39,26 @@ function Card({
|
|||||||
busy,
|
busy,
|
||||||
onEnter,
|
onEnter,
|
||||||
onReject,
|
onReject,
|
||||||
|
onExtraInfo,
|
||||||
}: {
|
}: {
|
||||||
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
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장'
|
const isDone = item.session_status === '협상완료'
|
||||||
|
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||||
|
const hasExtra = item.custom && Object.keys(item.custom).length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="rounded-md bg-neutral-20 px-1.5 py-0.5 text-[11px] font-semibold text-neutral-70">
|
|
||||||
{item.qt_type || '-'}
|
|
||||||
</span>
|
|
||||||
<span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span>
|
<span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p>
|
<p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p>
|
||||||
@ -76,8 +78,17 @@ function Card({
|
|||||||
<span>마감 {formatKstDateTime(item.qt_end_time)}</span>
|
<span>마감 {formatKstDateTime(item.qt_end_time)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(canEnter || canReject) && (
|
{(canEnter || canReject || isDone) && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
|
{isDone && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExtraInfo(item)}
|
||||||
|
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canReject && (
|
{canReject && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -10,19 +10,19 @@ interface WorkspaceTableProps {
|
|||||||
busyId?: string | null
|
busyId?: string | null
|
||||||
onEnter: (item: ListItem) => void
|
onEnter: (item: ListItem) => void
|
||||||
onReject: (item: ListItem) => void
|
onReject: (item: ListItem) => void
|
||||||
|
onExtraInfo: (item: ListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
|
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
|
||||||
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
||||||
|
|
||||||
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }: WorkspaceTableProps) {
|
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceTableProps) {
|
||||||
return (
|
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={HEAD}>견적번호</th>
|
||||||
<th className={HEAD}>구분</th>
|
|
||||||
<th className={HEAD}>상품</th>
|
<th className={HEAD}>상품</th>
|
||||||
<th className={HEAD}>제조사</th>
|
<th className={HEAD}>제조사</th>
|
||||||
<th className={cn(HEAD, 'text-center')}>상태</th>
|
<th className={cn(HEAD, 'text-center')}>상태</th>
|
||||||
@ -41,7 +41,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }:
|
|||||||
</StateRow>
|
</StateRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} />
|
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -55,27 +55,26 @@ function Row({
|
|||||||
busy,
|
busy,
|
||||||
onEnter,
|
onEnter,
|
||||||
onReject,
|
onReject,
|
||||||
|
onExtraInfo,
|
||||||
}: {
|
}: {
|
||||||
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
|
||||||
}) {
|
}) {
|
||||||
const meta = statusMeta(item.session_status)
|
const meta = statusMeta(item.session_status)
|
||||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||||
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장'
|
const isDone = item.session_status === '협상완료'
|
||||||
|
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||||
|
const hasExtra = item.custom && Object.keys(item.custom).length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
||||||
<td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}>
|
<td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}>
|
||||||
{item.qt_number || '-'}
|
{item.qt_number || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(CELL, 'whitespace-nowrap')}>
|
|
||||||
<span className="rounded-md bg-neutral-20 px-2 py-0.5 text-xs font-semibold text-neutral-70">
|
|
||||||
{item.qt_type || '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className={CELL}>
|
<td className={CELL}>
|
||||||
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
|
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
|
||||||
<p className="mt-0.5 text-xs text-neutral-60">
|
<p className="mt-0.5 text-xs text-neutral-60">
|
||||||
@ -93,6 +92,15 @@ function Row({
|
|||||||
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td>
|
<td className={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">
|
||||||
|
{isDone && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExtraInfo(item)}
|
||||||
|
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canReject && (
|
{canReject && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -124,7 +132,7 @@ function Row({
|
|||||||
function StateRow({ children }: { children: ReactNode }) {
|
function StateRow({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-20">
|
<td colSpan={6} 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>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { 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 { Search } from 'lucide-react'
|
||||||
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation } from '@/apis'
|
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from '@/apis'
|
||||||
import { cn, toast } from '@/lib'
|
import { 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'
|
||||||
@ -12,6 +12,7 @@ import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
|
|||||||
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
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 type { ListItem } from '@/features/list/types'
|
import type { ListItem } from '@/features/list/types'
|
||||||
|
|
||||||
// 상태별 거부 불가 안내
|
// 상태별 거부 불가 안내
|
||||||
@ -21,8 +22,6 @@ const REJECT_BLOCKED: Record<string, string> = {
|
|||||||
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
|
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_OPTIONS = ['재견적', '재협상', '신규견적', '신규협상']
|
|
||||||
|
|
||||||
export function ListWorkspace() {
|
export function ListWorkspace() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: user } = useMeQuery()
|
const { data: user } = useMeQuery()
|
||||||
@ -30,19 +29,19 @@ export function ListWorkspace() {
|
|||||||
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
|
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
|
||||||
const participate = useParticipateMutation()
|
const participate = useParticipateMutation()
|
||||||
const reject = useRejectMutation()
|
const reject = useRejectMutation()
|
||||||
|
const saveExtra = useSaveExtraInfoMutation()
|
||||||
|
|
||||||
const { selectedStatus, setStatus, selectedType, setType } = useListStore(
|
const { selectedStatus, setStatus } = useListStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
selectedStatus: s.selectedStatus,
|
selectedStatus: s.selectedStatus,
|
||||||
setStatus: s.setStatus,
|
setStatus: s.setStatus,
|
||||||
selectedType: s.selectedType,
|
|
||||||
setType: s.setType,
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
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 handleEnter = (item: ListItem) => {
|
const handleEnter = (item: ListItem) => {
|
||||||
setEnteringId(item.session_id)
|
setEnteringId(item.session_id)
|
||||||
@ -75,6 +74,17 @@ export function ListWorkspace() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExtraSubmit = (custom: Record<string, unknown>) => {
|
||||||
|
if (!extraTarget) return
|
||||||
|
saveExtra.mutate(
|
||||||
|
{ sessionId: extraTarget.session_id, request: { custom } },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success('부가정보가 저장되었습니다.'),
|
||||||
|
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* 환영 배너 */}
|
{/* 환영 배너 */}
|
||||||
@ -97,18 +107,6 @@ export function ListWorkspace() {
|
|||||||
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<StatusTabs selected={selectedStatus} onSelect={setStatus} />
|
<StatusTabs selected={selectedStatus} onSelect={setStatus} />
|
||||||
<div className="flex w-full items-center gap-2 lg:w-auto">
|
<div className="flex w-full items-center gap-2 lg:w-auto">
|
||||||
<select
|
|
||||||
value={selectedType ?? ''}
|
|
||||||
onChange={(e) => setType(e.target.value || null)}
|
|
||||||
className="h-9 shrink-0 rounded-xl border border-border bg-white px-3 text-xs font-semibold text-neutral-80 outline-none transition-all focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
|
|
||||||
>
|
|
||||||
<option value="">구분 전체</option>
|
|
||||||
{TYPE_OPTIONS.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{t}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="relative flex-1 lg:flex-none">
|
<div className="relative flex-1 lg:flex-none">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
|
||||||
<input
|
<input
|
||||||
@ -132,6 +130,7 @@ export function ListWorkspace() {
|
|||||||
busyId={participate.isPending ? enteringId : null}
|
busyId={participate.isPending ? enteringId : null}
|
||||||
onEnter={handleEnter}
|
onEnter={handleEnter}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
|
onExtraInfo={setExtraTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@ -141,6 +140,7 @@ export function ListWorkspace() {
|
|||||||
busyId={participate.isPending ? enteringId : null}
|
busyId={participate.isPending ? enteringId : null}
|
||||||
onEnter={handleEnter}
|
onEnter={handleEnter}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
|
onExtraInfo={setExtraTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,6 +152,10 @@ export function ListWorkspace() {
|
|||||||
{rejectTarget && (
|
{rejectTarget && (
|
||||||
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{extraTarget && (
|
||||||
|
<ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useSessionListQuery } from '@/apis'
|
import { useSessionListQuery } from '@/apis'
|
||||||
import { useListStore } from '@/features/list/stores/useListStore'
|
import { useListStore } from '@/features/list/stores/useListStore'
|
||||||
import { deadlineToOrder, statusLabelToCode, toListItem, typeLabelToCode } from '@/features/list/lib/adapter'
|
import { deadlineToOrder, statusLabelToCode, toListItem } from '@/features/list/lib/adapter'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
||||||
export function useList() {
|
export function useList() {
|
||||||
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
|
const { selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
|
||||||
|
|
||||||
const query = useSessionListQuery({
|
const query = useSessionListQuery({
|
||||||
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
||||||
qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
|
|
||||||
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
|
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
|
|||||||
@ -37,5 +37,6 @@ export function toListItem(api: SessionListItem): ListItem {
|
|||||||
item_name: api.item_name,
|
item_name: api.item_name,
|
||||||
model_name: api.model_name,
|
model_name: api.model_name,
|
||||||
maker_name: api.maker_name,
|
maker_name: api.maker_name,
|
||||||
|
custom: api.custom ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,5 @@ export type ListItem = {
|
|||||||
item_name: string
|
item_name: string
|
||||||
model_name: string
|
model_name: string
|
||||||
maker_name: string
|
maker_name: string
|
||||||
|
custom: Record<string, unknown> // 협상완료 부가정보(입력값). 미입력이면 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import { Logo } from '@/components'
|
import { Logo } from '@/components'
|
||||||
|
import { useMeQuery } from '@/apis'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
|
|
||||||
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
|
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
|
||||||
@ -43,11 +44,12 @@ export function MainLayout({
|
|||||||
header,
|
header,
|
||||||
children,
|
children,
|
||||||
}: MainLayoutProps) {
|
}: MainLayoutProps) {
|
||||||
|
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
|
||||||
const panes = (
|
const panes = (
|
||||||
<>
|
<>
|
||||||
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
|
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
|
||||||
<div className={styles.logoHeader}>
|
<div className={styles.logoHeader}>
|
||||||
<Logo size="sm" />
|
<Logo size="sm" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
|
||||||
{logoAction}
|
{logoAction}
|
||||||
</div>
|
</div>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function PortalHeader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6">
|
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6">
|
||||||
<Logo size="md" />
|
<Logo size="md" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -47,6 +47,8 @@ class companies(MainTableMixin, MAIN_BASE):
|
|||||||
website_url = Column(String(255), nullable=True)
|
website_url = Column(String(255), nullable=True)
|
||||||
industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
|
industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
|
||||||
status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
|
status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
|
||||||
|
# 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)/item_fields·supplier_fields(커스텀필드 정의)
|
||||||
|
settings = Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class users(MainTableMixin, MAIN_BASE):
|
class users(MainTableMixin, MAIN_BASE):
|
||||||
@ -109,6 +111,8 @@ class items(MainTableMixin, MAIN_BASE):
|
|||||||
delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드
|
delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드
|
||||||
vat_yn = Column(Boolean, nullable=True)
|
vat_yn = Column(Boolean, nullable=True)
|
||||||
delivery_fee_yn = Column(Boolean, nullable=True)
|
delivery_fee_yn = Column(Boolean, nullable=True)
|
||||||
|
# 회사 커스텀필드 값. 정의(키/라벨/타입)는 companies.settings.item_fields, 여기는 {key: value}만
|
||||||
|
custom = Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
|
class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
|
||||||
@ -145,6 +149,8 @@ class suppliers(MainTableMixin, MAIN_BASE):
|
|||||||
manager_email = Column(String(255), nullable=True)
|
manager_email = Column(String(255), nullable=True)
|
||||||
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
|
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
|
||||||
total_revenue = Column(BigInteger, nullable=True) # 총매출액
|
total_revenue = Column(BigInteger, nullable=True) # 총매출액
|
||||||
|
# 회사 커스텀필드 값. 정의는 companies.settings.supplier_fields, 여기는 {key: value}만
|
||||||
|
custom = Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class supplier_users(MainTableMixin, MAIN_BASE):
|
class supplier_users(MainTableMixin, MAIN_BASE):
|
||||||
@ -316,6 +322,7 @@ class sessions(MainTableMixin, MAIN_BASE):
|
|||||||
reject_price = Column(BigInteger, nullable=True)
|
reject_price = Column(BigInteger, nullable=True)
|
||||||
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
|
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
|
||||||
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
|
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
|
||||||
|
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
|
||||||
|
|
||||||
|
|
||||||
class chats(MainTableMixin, MAIN_BASE):
|
class chats(MainTableMixin, MAIN_BASE):
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
|||||||
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, users
|
from common.database.model.models import items, users, supplier_items, suppliers
|
||||||
from common.enums import ErrorType
|
from common.enums import ErrorType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -49,6 +49,10 @@ class IItemCRUD(ABC):
|
|||||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ItemCRUD(IItemCRUD):
|
class ItemCRUD(IItemCRUD):
|
||||||
async def search(
|
async def search(
|
||||||
@ -184,3 +188,29 @@ class ItemCRUD(IItemCRUD):
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, {}
|
return ErrorType.DB_RUN_FAILED, {}
|
||||||
|
|
||||||
|
async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||||
|
"""item_id 목록 → {item_id: [공급사명...]}. 상품 목록 '공급사' 컬럼용(supplier_items→suppliers 배치 조인)."""
|
||||||
|
try:
|
||||||
|
if not item_ids:
|
||||||
|
return ErrorType.SUCCESS, {}
|
||||||
|
query = (
|
||||||
|
select(supplier_items.item_id, suppliers.name)
|
||||||
|
.join(suppliers, suppliers.supplier_id == supplier_items.supplier_id)
|
||||||
|
.where(
|
||||||
|
supplier_items.item_id.in_(item_ids),
|
||||||
|
supplier_items.deleted == False, # noqa: E712
|
||||||
|
suppliers.deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(suppliers.name)
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, {}
|
||||||
|
out: dict = {}
|
||||||
|
for item_id, name in rows:
|
||||||
|
out.setdefault(item_id, []).append(name)
|
||||||
|
return ErrorType.SUCCESS, out
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, {}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from sqlalchemy import select, func, and_, case
|
from sqlalchemy import select, func, and_, case
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
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
|
||||||
@ -54,6 +55,14 @@ class IStatisticsCRUD(ABC):
|
|||||||
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||||
pass
|
pass
|
||||||
@ -187,6 +196,87 @@ class StatisticsCRUD(IStatisticsCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, 0.0
|
return ErrorType.DB_RUN_FAILED, 0.0
|
||||||
|
|
||||||
|
async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||||
|
# 인상억제율(재협상 전용): 같은 견적번호(qt_number)의 직전 라운드 투찰가 대비 이번 라운드 투찰가가
|
||||||
|
# 얼마나 안 올랐나 = avg((직전투찰 − 이번투찰) / 직전투찰). 양수=인하(억제 성공), 음수=인상 허용.
|
||||||
|
# 직전·이번 둘 다 유효 투찰(bid_price)이 있는 재협상 쌍만 대상(직전이 개찰/거부면 비교 불가 → 제외).
|
||||||
|
# 새 컬럼 없이 sessions.qt_number+qt_round+bid_price 로만 파생.
|
||||||
|
try:
|
||||||
|
prev = aliased(sessions)
|
||||||
|
stmt = (
|
||||||
|
select(func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
|
||||||
|
.select_from(sessions)
|
||||||
|
.join(
|
||||||
|
prev,
|
||||||
|
and_(
|
||||||
|
prev.qt_number == sessions.qt_number,
|
||||||
|
prev.item_id == sessions.item_id,
|
||||||
|
prev.supplier_id == sessions.supplier_id,
|
||||||
|
prev.qt_round == sessions.qt_round - 1,
|
||||||
|
prev.bid_price.isnot(None),
|
||||||
|
prev.bid_price > 0,
|
||||||
|
prev.deleted == False, # noqa: E712
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
*_company_scope(company_id, owner),
|
||||||
|
sessions.bid_price.isnot(None),
|
||||||
|
sessions.qt_round >= 2,
|
||||||
|
sessions.deleted == False, # noqa: E712
|
||||||
|
quotations.updated_at >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||||
|
if err != ErrorType.SUCCESS:
|
||||||
|
return err, 0.0
|
||||||
|
val = rows[0] if rows else None
|
||||||
|
return ErrorType.SUCCESS, float(val) if val is not None else 0.0
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, 0.0
|
||||||
|
|
||||||
|
async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||||
|
# 월별 인상억제율: 이번 라운드 마감월(quotations.updated_at)별 avg((직전투찰 − 이번투찰)/직전투찰).
|
||||||
|
try:
|
||||||
|
prev = aliased(sessions)
|
||||||
|
month = func.to_char(quotations.updated_at, "YYYY-MM")
|
||||||
|
stmt = (
|
||||||
|
select(month.label("m"), func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
|
||||||
|
.select_from(sessions)
|
||||||
|
.join(
|
||||||
|
prev,
|
||||||
|
and_(
|
||||||
|
prev.qt_number == sessions.qt_number,
|
||||||
|
prev.item_id == sessions.item_id,
|
||||||
|
prev.supplier_id == sessions.supplier_id,
|
||||||
|
prev.qt_round == sessions.qt_round - 1,
|
||||||
|
prev.bid_price.isnot(None),
|
||||||
|
prev.bid_price > 0,
|
||||||
|
prev.deleted == False, # noqa: E712
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
*_company_scope(company_id, owner),
|
||||||
|
sessions.bid_price.isnot(None),
|
||||||
|
sessions.qt_round >= 2,
|
||||||
|
sessions.deleted == False, # noqa: E712
|
||||||
|
quotations.updated_at >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(month)
|
||||||
|
.order_by(month)
|
||||||
|
)
|
||||||
|
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||||
|
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||||
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
|
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from sqlalchemy import select, func, update
|
|||||||
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 supplier_items, items
|
from common.database.model.models import supplier_items, items, suppliers
|
||||||
from common.enums import ErrorType
|
from common.enums import ErrorType
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
@ -48,7 +48,8 @@ class ISupplierItemCRUD(ABC):
|
|||||||
|
|
||||||
class SupplierItemCRUD(ISupplierItemCRUD):
|
class SupplierItemCRUD(ISupplierItemCRUD):
|
||||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]:
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]:
|
||||||
# 협력사 상세용: 매핑 + 상품명/코드 조인. Row(supplier_item_id, item_id, name, code, supply_type)
|
# 협력사 상세용: 매핑 + 상품명/코드/카테고리/제조사 조인.
|
||||||
|
# Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer)
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
@ -57,6 +58,8 @@ class SupplierItemCRUD(ISupplierItemCRUD):
|
|||||||
items.name,
|
items.name,
|
||||||
items.code,
|
items.code,
|
||||||
supplier_items.supply_type,
|
supplier_items.supply_type,
|
||||||
|
items.category,
|
||||||
|
items.manufacturer,
|
||||||
)
|
)
|
||||||
.join(items, items.item_id == supplier_items.item_id)
|
.join(items, items.item_id == supplier_items.item_id)
|
||||||
.where(
|
.where(
|
||||||
@ -75,11 +78,22 @@ class SupplierItemCRUD(ISupplierItemCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]:
|
async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]:
|
||||||
# 견적생성 모달용: 이 상품을 취급하는 협력사별 공급유형. Row(supplier_id, supply_type)
|
# 견적생성 모달·상품 상세 공용: 이 상품을 취급하는 협력사별 공급유형.
|
||||||
|
# Row(supplier_id, supply_type, name, supplier_item_id)
|
||||||
try:
|
try:
|
||||||
query = select(supplier_items.supplier_id, supplier_items.supply_type).where(
|
query = (
|
||||||
|
select(
|
||||||
|
supplier_items.supplier_id,
|
||||||
|
supplier_items.supply_type,
|
||||||
|
suppliers.name,
|
||||||
|
supplier_items.supplier_item_id,
|
||||||
|
)
|
||||||
|
.join(suppliers, suppliers.supplier_id == supplier_items.supplier_id)
|
||||||
|
.where(
|
||||||
supplier_items.item_id == item_id,
|
supplier_items.item_id == item_id,
|
||||||
supplier_items.deleted == False, # noqa: E712
|
supplier_items.deleted == False, # noqa: E712
|
||||||
|
suppliers.deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
)
|
)
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
|||||||
@ -35,6 +35,10 @@ class IUserCRUD(ABC):
|
|||||||
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||||
pass
|
pass
|
||||||
@ -103,6 +107,18 @@ class UserCRUD(IUserCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(companies)
|
||||||
|
.where(companies.company_id == company_id, companies.deleted == False) # noqa: E712
|
||||||
|
.values(settings=settings, updated_at=GTime.UTC())
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def list_by_company(
|
async def list_by_company(
|
||||||
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
|
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
|
||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from config.server_configs import web_server_config
|
|||||||
from scheduler import shutdown_scheduler, start_scheduler
|
from scheduler import shutdown_scheduler, start_scheduler
|
||||||
import router.v1.auth.account
|
import router.v1.auth.account
|
||||||
import router.v1.company.user
|
import router.v1.company.user
|
||||||
|
import router.v1.company.settings
|
||||||
import router.v1.item.item
|
import router.v1.item.item
|
||||||
import router.v1.supplier.supplier
|
import router.v1.supplier.supplier
|
||||||
import router.v1.supplier_item.supplier_item
|
import router.v1.supplier_item.supplier_item
|
||||||
@ -68,6 +69,7 @@ async def healthz():
|
|||||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||||
app.include_router(router.v1.auth.account.router)
|
app.include_router(router.v1.auth.account.router)
|
||||||
app.include_router(router.v1.company.user.router)
|
app.include_router(router.v1.company.user.router)
|
||||||
|
app.include_router(router.v1.company.settings.router)
|
||||||
app.include_router(router.v1.item.item.router)
|
app.include_router(router.v1.item.item.router)
|
||||||
app.include_router(router.v1.supplier.supplier.router)
|
app.include_router(router.v1.supplier.supplier.router)
|
||||||
app.include_router(router.v1.supplier_item.supplier_item.router)
|
app.include_router(router.v1.supplier_item.supplier_item.router)
|
||||||
|
|||||||
@ -56,3 +56,16 @@ class Res_CompanyUserList(Res_PageProtocol):
|
|||||||
|
|
||||||
class Res_DeleteCompanyUser(Res_WebPacketProtocol):
|
class Res_DeleteCompanyUser(Res_WebPacketProtocol):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CompanySettingsProtocol(WebPacketProtocol):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Req_UpdateCompanySettings(CompanySettingsProtocol):
|
||||||
|
# settings 전체 치환. 서브키: branding/labels/features/item_fields/supplier_fields/session_fields
|
||||||
|
settings: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Res_CompanySettings(Res_WebPacketProtocol):
|
||||||
|
settings: Optional[dict] = None
|
||||||
|
|||||||
21
negodata/backend/router/v1/company/settings.py
Normal file
21
negodata/backend/router/v1/company/settings.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from common.models.gmodel import UserInfo
|
||||||
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
|
||||||
|
from services.company_settings_service import CompanySettingsService
|
||||||
|
from .protocol import Req_UpdateCompanySettings, Res_CompanySettings
|
||||||
|
|
||||||
|
# 회사별 커스터마이징 설정. 조회=로그인 유저 전원(앱 부팅 시 브랜딩/라벨 로드), 수정=최고관리자(OWNER) 전용.
|
||||||
|
router = APIRouter(prefix="/v1/company/settings", tags=["CompanySettings"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(path="", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 조회")
|
||||||
|
async def get_settings(service: CompanySettingsService = Depends(), user: UserInfo = Depends(IsValidAccessToken)):
|
||||||
|
return RemoveNoneResponse(await service.get_settings(user.company_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(path="/update", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 수정(최고관리자)")
|
||||||
|
async def update_settings(
|
||||||
|
req: Req_UpdateCompanySettings, service: CompanySettingsService = Depends(), owner: UserInfo = Depends(RequireOwner)
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.update_settings(owner.company_id, req))
|
||||||
@ -33,6 +33,7 @@ class Req_CreateItem(ItemProtocol):
|
|||||||
delivery_type: Optional[int] = None
|
delivery_type: Optional[int] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_yn: Optional[bool] = None
|
delivery_fee_yn: Optional[bool] = None
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields)
|
||||||
|
|
||||||
|
|
||||||
class Req_UpdateItem(ItemProtocol):
|
class Req_UpdateItem(ItemProtocol):
|
||||||
@ -56,6 +57,7 @@ class Req_UpdateItem(ItemProtocol):
|
|||||||
delivery_type: Optional[int] = None
|
delivery_type: Optional[int] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_yn: Optional[bool] = None
|
delivery_fee_yn: Optional[bool] = None
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||||
|
|
||||||
|
|
||||||
class ItemData(WebPacketProtocol):
|
class ItemData(WebPacketProtocol):
|
||||||
@ -85,6 +87,8 @@ class ItemData(WebPacketProtocol):
|
|||||||
delivery_type: Optional[DeliveryType] = None
|
delivery_type: Optional[DeliveryType] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_yn: Optional[bool] = None
|
delivery_fee_yn: Optional[bool] = None
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||||
|
supplier_names: list[str] = [] # 이 상품을 취급(계약)하는 공급사명 목록(목록 컬럼용, 배치 조인)
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
@ -111,6 +111,7 @@ class SessionData(WebPacketProtocol):
|
|||||||
reject_price: Optional[int] = None
|
reject_price: Optional[int] = None
|
||||||
reject_delivery_type: Optional[DeliveryType] = None
|
reject_delivery_type: Optional[DeliveryType] = None
|
||||||
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
|
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
|
||||||
|
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
|
||||||
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,12 +15,18 @@ class StatKpi(WebPacketProtocol):
|
|||||||
savings_delta_mom: int = 0 # 전월 대비 절감액 증감
|
savings_delta_mom: int = 0 # 전월 대비 절감액 증감
|
||||||
closed_count: int = 0 # 마감 견적 수(창)
|
closed_count: int = 0 # 마감 견적 수(창)
|
||||||
regen_avg_round: float = 0.0 # 평균 재견적 라운드
|
regen_avg_round: float = 0.0 # 평균 재견적 라운드
|
||||||
|
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
|
||||||
|
|
||||||
|
|
||||||
class StatMonthPoint(WebPacketProtocol):
|
class StatMonthPoint(WebPacketProtocol):
|
||||||
month: str # 'YYYY-MM'
|
month: str # 'YYYY-MM'
|
||||||
savings: int = 0
|
savings: int = 0
|
||||||
rate: float = 0.0
|
rate: float = 0.0 # 절감률
|
||||||
|
|
||||||
|
|
||||||
|
class StatMarkupPoint(WebPacketProtocol):
|
||||||
|
month: str # 'YYYY-MM'
|
||||||
|
rate: float = 0.0 # 인상억제율(직전 라운드 투찰가 대비 인하율)
|
||||||
|
|
||||||
|
|
||||||
class StatOutcome(WebPacketProtocol):
|
class StatOutcome(WebPacketProtocol):
|
||||||
@ -60,6 +66,7 @@ class StatCardUsage(WebPacketProtocol):
|
|||||||
class StatScope(WebPacketProtocol):
|
class StatScope(WebPacketProtocol):
|
||||||
kpi: StatKpi = Field(default_factory=StatKpi)
|
kpi: StatKpi = Field(default_factory=StatKpi)
|
||||||
trend: list[StatMonthPoint] = []
|
trend: list[StatMonthPoint] = []
|
||||||
|
markup_trend: list[StatMarkupPoint] = [] # 월별 인상억제율(재협상)
|
||||||
outcome: StatOutcome = Field(default_factory=StatOutcome)
|
outcome: StatOutcome = Field(default_factory=StatOutcome)
|
||||||
participation: StatParticipation = Field(default_factory=StatParticipation)
|
participation: StatParticipation = Field(default_factory=StatParticipation)
|
||||||
type_split: list[StatTypeRow] = []
|
type_split: list[StatTypeRow] = []
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class Req_CreateSupplier(SupplierProtocol):
|
|||||||
manager_email: Optional[str] = None
|
manager_email: Optional[str] = None
|
||||||
manager_contact_number: Optional[str] = None
|
manager_contact_number: Optional[str] = None
|
||||||
total_revenue: Optional[int] = None # 총매출액(원)
|
total_revenue: Optional[int] = None # 총매출액(원)
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.supplier_fields)
|
||||||
|
|
||||||
|
|
||||||
class Req_UpdateSupplier(SupplierProtocol):
|
class Req_UpdateSupplier(SupplierProtocol):
|
||||||
@ -27,6 +28,7 @@ class Req_UpdateSupplier(SupplierProtocol):
|
|||||||
manager_email: Optional[str] = None
|
manager_email: Optional[str] = None
|
||||||
manager_contact_number: Optional[str] = None
|
manager_contact_number: Optional[str] = None
|
||||||
total_revenue: Optional[int] = None
|
total_revenue: Optional[int] = None
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||||
|
|
||||||
|
|
||||||
class SupplierData(WebPacketProtocol):
|
class SupplierData(WebPacketProtocol):
|
||||||
@ -42,6 +44,7 @@ class SupplierData(WebPacketProtocol):
|
|||||||
manager_email: Optional[str] = None
|
manager_email: Optional[str] = None
|
||||||
manager_contact_number: Optional[str] = None
|
manager_contact_number: Optional[str] = None
|
||||||
total_revenue: Optional[int] = None # 총매출액(원)
|
total_revenue: Optional[int] = None # 총매출액(원)
|
||||||
|
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
|
||||||
account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급
|
account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급
|
||||||
account_status: Optional[int] = None # SupplierUserStatus. None=미발급
|
account_status: Optional[int] = None # SupplierUserStatus. None=미발급
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|||||||
@ -33,6 +33,8 @@ class SupplierItemData(WebPacketProtocol):
|
|||||||
item_name: str
|
item_name: str
|
||||||
item_code: Optional[str] = None
|
item_code: Optional[str] = None
|
||||||
supply_type: int
|
supply_type: int
|
||||||
|
item_category: Optional[str] = None
|
||||||
|
item_manufacturer: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@ -40,6 +42,8 @@ class SupplierItemData(WebPacketProtocol):
|
|||||||
class ItemSupplyType(WebPacketProtocol):
|
class ItemSupplyType(WebPacketProtocol):
|
||||||
supplier_id: uuid.UUID
|
supplier_id: uuid.UUID
|
||||||
supply_type: int
|
supply_type: int
|
||||||
|
supplier_name: Optional[str] = None
|
||||||
|
supplier_item_id: Optional[uuid.UUID] = None # 상품측 매핑 편집(수정/삭제)용
|
||||||
|
|
||||||
|
|
||||||
class Res_SupplierItem(Res_WebPacketProtocol):
|
class Res_SupplierItem(Res_WebPacketProtocol):
|
||||||
|
|||||||
45
negodata/backend/services/company_settings_service.py
Normal file
45
negodata/backend/services/company_settings_service.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import companies
|
||||||
|
from common.enums import DBWRType, ErrorType
|
||||||
|
from crud.user_crud import IUserCRUD, UserCRUD
|
||||||
|
from router.v1.company.protocol import Req_UpdateCompanySettings, Res_CompanySettings
|
||||||
|
|
||||||
|
|
||||||
|
class CompanySettingsService:
|
||||||
|
"""회사별 커스터마이징 설정(companies.settings JSONB) 조회/수정.
|
||||||
|
|
||||||
|
- 조회는 로그인 유저 전원(브랜딩/라벨을 앱 부팅 시 로드), 수정은 라우터에서 RequireOwner 로 게이트.
|
||||||
|
- company_id 는 토큰값만 쓴다 → 남의 회사 설정 접근 불가.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
|
||||||
|
self.user_crud = user_crud
|
||||||
|
|
||||||
|
async def get_settings(self, company_id: str) -> Res_CompanySettings:
|
||||||
|
res = Res_CompanySettings()
|
||||||
|
err_type, company = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
companies.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_company(s, uuid.UUID(company_id)),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or company is None:
|
||||||
|
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.ACCOUNT_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
res.settings = company.settings
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def update_settings(self, company_id: str, req: Req_UpdateCompanySettings) -> Res_CompanySettings:
|
||||||
|
res = Res_CompanySettings()
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[companies.DBType()],
|
||||||
|
[lambda s: self.user_crud.update_company_settings(s, uuid.UUID(company_id), req.settings)],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
res.settings = req.settings
|
||||||
|
return res
|
||||||
@ -67,6 +67,16 @@ class ItemService:
|
|||||||
if nm_err == ErrorType.SUCCESS:
|
if nm_err == ErrorType.SUCCESS:
|
||||||
for d in res.items:
|
for d in res.items:
|
||||||
d.creator_name = name_map.get(d.user_id)
|
d.creator_name = name_map.get(d.user_id)
|
||||||
|
# 공급사명 배치 조인 — 페이지 상품의 item_id를 모아 IN 쿼리 1회로 {item_id:[공급사명]} 맵을 만들어 매핑.
|
||||||
|
item_ids = [r.item_id for r in rows]
|
||||||
|
if item_ids:
|
||||||
|
sn_err, supplier_map = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
items.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.item_crud.supplier_name_map(s, item_ids),
|
||||||
|
)
|
||||||
|
if sn_err == ErrorType.SUCCESS:
|
||||||
|
for d in res.items:
|
||||||
|
d.supplier_names = supplier_map.get(d.item_id, [])
|
||||||
res.total = total
|
res.total = total
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
@ -877,6 +877,7 @@ class QuotationService:
|
|||||||
reject_price=r.reject_price,
|
reject_price=r.reject_price,
|
||||||
reject_delivery_type=r.reject_delivery_type,
|
reject_delivery_type=r.reject_delivery_type,
|
||||||
email_sent_at=r.email_sent_at,
|
email_sent_at=r.email_sent_at,
|
||||||
|
custom=r.custom,
|
||||||
url=self._session_chat_url(r.session_id),
|
url=self._session_chat_url(r.session_id),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from router.v1.statistics.protocol import (
|
|||||||
StatScope,
|
StatScope,
|
||||||
StatKpi,
|
StatKpi,
|
||||||
StatMonthPoint,
|
StatMonthPoint,
|
||||||
|
StatMarkupPoint,
|
||||||
StatOutcome,
|
StatOutcome,
|
||||||
StatParticipation,
|
StatParticipation,
|
||||||
StatTypeRow,
|
StatTypeRow,
|
||||||
@ -51,19 +52,22 @@ class StatisticsService:
|
|||||||
type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since))
|
type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since))
|
||||||
part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since))
|
part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since))
|
||||||
regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since))
|
regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since))
|
||||||
|
markup = await self._read_scalar(lambda s: self.stat_crud.markup_suppression(s, company_uuid, owner_uuid, since))
|
||||||
|
markup_rows = await self._read(lambda s: self.stat_crud.markup_suppression_monthly(s, company_uuid, owner_uuid, since))
|
||||||
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
|
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
|
||||||
|
|
||||||
scope.trend = self._trend(win_rows, labels)
|
scope.trend = self._trend(win_rows, labels)
|
||||||
|
scope.markup_trend = [StatMarkupPoint(month=r[0], rate=float(r[1] or 0.0)) for r in markup_rows]
|
||||||
scope.categories = self._categories(win_rows)
|
scope.categories = self._categories(win_rows)
|
||||||
scope.outcome = self._outcome(outcome_rows)
|
scope.outcome = self._outcome(outcome_rows)
|
||||||
scope.participation = self._participation(part_rows)
|
scope.participation = self._participation(part_rows)
|
||||||
scope.type_split = self._type_split(type_rows, win_rows)
|
scope.type_split = self._type_split(type_rows, win_rows)
|
||||||
scope.cards = self._cards(card_rows)
|
scope.cards = self._cards(card_rows)
|
||||||
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen)
|
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup)
|
||||||
return scope
|
return scope
|
||||||
|
|
||||||
# ── 파생 계산 ───────────────────────────────────────────────
|
# ── 파생 계산 ───────────────────────────────────────────────
|
||||||
def _kpi(self, win_rows, trend, outcome, regen) -> StatKpi:
|
def _kpi(self, win_rows, trend, outcome, regen, markup) -> StatKpi:
|
||||||
k = StatKpi()
|
k = StatKpi()
|
||||||
total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows)
|
total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows)
|
||||||
total_target = sum(int(r.target_price) for r in win_rows)
|
total_target = sum(int(r.target_price) for r in win_rows)
|
||||||
@ -75,6 +79,7 @@ class StatisticsService:
|
|||||||
k.closed_count = closed
|
k.closed_count = closed
|
||||||
k.award_rate = (outcome.awarded / closed) if closed else 0.0
|
k.award_rate = (outcome.awarded / closed) if closed else 0.0
|
||||||
k.regen_avg_round = round(regen, 2)
|
k.regen_avg_round = round(regen, 2)
|
||||||
|
k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생)
|
||||||
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
|
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
|
||||||
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
|
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
|
||||||
return k
|
return k
|
||||||
|
|||||||
@ -85,10 +85,11 @@ class SupplierItemService:
|
|||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
# Row(supplier_item_id, item_id, name, code, supply_type)
|
# Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer)
|
||||||
res.supplier_items = [
|
res.supplier_items = [
|
||||||
SupplierItemData(
|
SupplierItemData(
|
||||||
supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4]
|
supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4],
|
||||||
|
item_category=r[5], item_manufacturer=r[6],
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -109,8 +110,10 @@ class SupplierItemService:
|
|||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
# Row(supplier_id, supply_type)
|
# Row(supplier_id, supply_type, name, supplier_item_id)
|
||||||
res.suppliers = [ItemSupplyType(supplier_id=r[0], supply_type=r[1]) for r in rows]
|
res.suppliers = [
|
||||||
|
ItemSupplyType(supplier_id=r[0], supply_type=r[1], supplier_name=r[2], supplier_item_id=r[3]) for r in rows
|
||||||
|
]
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem:
|
async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem:
|
||||||
|
|||||||
@ -170,6 +170,7 @@ class SupplierService:
|
|||||||
manager_email=req.manager_email,
|
manager_email=req.manager_email,
|
||||||
manager_contact_number=req.manager_contact_number,
|
manager_contact_number=req.manager_contact_number,
|
||||||
total_revenue=req.total_revenue,
|
total_revenue=req.total_revenue,
|
||||||
|
custom=req.custom,
|
||||||
)
|
)
|
||||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
[suppliers.DBType()],
|
[suppliers.DBType()],
|
||||||
|
|||||||
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
ReqUpdateCompanySettings,
|
||||||
|
ResCompanySettings
|
||||||
|
} from '.././model';
|
||||||
|
|
||||||
|
import { customFetch } from '../../mutator/custom-fetch';
|
||||||
|
|
||||||
|
|
||||||
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 회사 커스터마이징 설정 조회
|
||||||
|
*/
|
||||||
|
export const getSettings = (
|
||||||
|
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResCompanySettings>(
|
||||||
|
{url: `/v1/company/settings`, method: 'GET', signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetSettingsQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/v1/company/settings`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetSettingsQueryOptions = <TData = Awaited<ReturnType<typeof getSettings>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetSettingsQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSettings>>> = ({ signal }) => getSettings(requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetSettingsQueryResult = NonNullable<Awaited<ReturnType<typeof getSettings>>>
|
||||||
|
export type GetSettingsQueryError = void
|
||||||
|
|
||||||
|
|
||||||
|
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||||
|
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getSettings>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getSettings>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getSettings>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getSettings>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary 회사 커스터마이징 설정 조회
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getGetSettingsQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey ;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 회사 커스터마이징 설정 수정(최고관리자)
|
||||||
|
*/
|
||||||
|
export const updateSettings = (
|
||||||
|
reqUpdateCompanySettings: ReqUpdateCompanySettings,
|
||||||
|
options?: SecondParameter<typeof customFetch>,) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResCompanySettings>(
|
||||||
|
{url: `/v1/company/settings/update`, method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json', },
|
||||||
|
data: reqUpdateCompanySettings
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getUpdateSettingsMutationOptions = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['updateSettings'];
|
||||||
|
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 updateSettings>>, {data: ReqUpdateCompanySettings}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return updateSettings(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type UpdateSettingsMutationResult = NonNullable<Awaited<ReturnType<typeof updateSettings>>>
|
||||||
|
export type UpdateSettingsMutationBody = ReqUpdateCompanySettings
|
||||||
|
export type UpdateSettingsMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 회사 커스터마이징 설정 수정(최고관리자)
|
||||||
|
*/
|
||||||
|
export const useUpdateSettings = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof updateSettings>>,
|
||||||
|
TError,
|
||||||
|
{data: ReqUpdateCompanySettings},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getUpdateSettingsMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
@ -55,6 +55,8 @@ export * from './itemDataCategory';
|
|||||||
export * from './itemDataCode';
|
export * from './itemDataCode';
|
||||||
export * from './itemDataCreatedAt';
|
export * from './itemDataCreatedAt';
|
||||||
export * from './itemDataCreatorName';
|
export * from './itemDataCreatorName';
|
||||||
|
export * from './itemDataCustom';
|
||||||
|
export * from './itemDataCustomAnyOf';
|
||||||
export * from './itemDataDeliveryFeeYn';
|
export * from './itemDataDeliveryFeeYn';
|
||||||
export * from './itemDataDeliveryType';
|
export * from './itemDataDeliveryType';
|
||||||
export * from './itemDataImageUrl';
|
export * from './itemDataImageUrl';
|
||||||
@ -72,6 +74,8 @@ export * from './itemDataSpec';
|
|||||||
export * from './itemDataUpdatedAt';
|
export * from './itemDataUpdatedAt';
|
||||||
export * from './itemDataVatYn';
|
export * from './itemDataVatYn';
|
||||||
export * from './itemSupplyType';
|
export * from './itemSupplyType';
|
||||||
|
export * from './itemSupplyTypeSupplierItemId';
|
||||||
|
export * from './itemSupplyTypeSupplierName';
|
||||||
export * from './listCardsParams';
|
export * from './listCardsParams';
|
||||||
export * from './listItemsParams';
|
export * from './listItemsParams';
|
||||||
export * from './listNotificationsParams';
|
export * from './listNotificationsParams';
|
||||||
@ -139,6 +143,8 @@ export * from './reqCreateCompanyUser';
|
|||||||
export * from './reqCreateItem';
|
export * from './reqCreateItem';
|
||||||
export * from './reqCreateItemCategory';
|
export * from './reqCreateItemCategory';
|
||||||
export * from './reqCreateItemCode';
|
export * from './reqCreateItemCode';
|
||||||
|
export * from './reqCreateItemCustom';
|
||||||
|
export * from './reqCreateItemCustomAnyOf';
|
||||||
export * from './reqCreateItemDeliveryFeeYn';
|
export * from './reqCreateItemDeliveryFeeYn';
|
||||||
export * from './reqCreateItemDeliveryType';
|
export * from './reqCreateItemDeliveryType';
|
||||||
export * from './reqCreateItemImageUrl';
|
export * from './reqCreateItemImageUrl';
|
||||||
@ -168,6 +174,8 @@ export * from './reqCreateQuotationVersionId';
|
|||||||
export * from './reqCreateSupplier';
|
export * from './reqCreateSupplier';
|
||||||
export * from './reqCreateSupplierAccount';
|
export * from './reqCreateSupplierAccount';
|
||||||
export * from './reqCreateSupplierCode';
|
export * from './reqCreateSupplierCode';
|
||||||
|
export * from './reqCreateSupplierCustom';
|
||||||
|
export * from './reqCreateSupplierCustomAnyOf';
|
||||||
export * from './reqCreateSupplierItem';
|
export * from './reqCreateSupplierItem';
|
||||||
export * from './reqCreateSupplierManagerContactNumber';
|
export * from './reqCreateSupplierManagerContactNumber';
|
||||||
export * from './reqCreateSupplierManagerEmail';
|
export * from './reqCreateSupplierManagerEmail';
|
||||||
@ -186,6 +194,8 @@ export * from './reqUpdateCardNumber';
|
|||||||
export * from './reqUpdateCardScript';
|
export * from './reqUpdateCardScript';
|
||||||
export * from './reqUpdateCardStatus';
|
export * from './reqUpdateCardStatus';
|
||||||
export * from './reqUpdateCardUsageType';
|
export * from './reqUpdateCardUsageType';
|
||||||
|
export * from './reqUpdateCompanySettings';
|
||||||
|
export * from './reqUpdateCompanySettingsSettings';
|
||||||
export * from './reqUpdateCompanyUser';
|
export * from './reqUpdateCompanyUser';
|
||||||
export * from './reqUpdateCompanyUserContactNumber';
|
export * from './reqUpdateCompanyUserContactNumber';
|
||||||
export * from './reqUpdateCompanyUserEmail';
|
export * from './reqUpdateCompanyUserEmail';
|
||||||
@ -196,6 +206,8 @@ export * from './reqUpdateItem';
|
|||||||
export * from './reqUpdateItemCategory';
|
export * from './reqUpdateItemCategory';
|
||||||
export * from './reqUpdateItemCategoryType';
|
export * from './reqUpdateItemCategoryType';
|
||||||
export * from './reqUpdateItemCode';
|
export * from './reqUpdateItemCode';
|
||||||
|
export * from './reqUpdateItemCustom';
|
||||||
|
export * from './reqUpdateItemCustomAnyOf';
|
||||||
export * from './reqUpdateItemDeliveryFeeYn';
|
export * from './reqUpdateItemDeliveryFeeYn';
|
||||||
export * from './reqUpdateItemDeliveryType';
|
export * from './reqUpdateItemDeliveryType';
|
||||||
export * from './reqUpdateItemImageUrl';
|
export * from './reqUpdateItemImageUrl';
|
||||||
@ -224,6 +236,8 @@ export * from './reqUpdateQuotationSettingTargetMarginRate';
|
|||||||
export * from './reqUpdateSupplier';
|
export * from './reqUpdateSupplier';
|
||||||
export * from './reqUpdateSupplierAccountStatus';
|
export * from './reqUpdateSupplierAccountStatus';
|
||||||
export * from './reqUpdateSupplierCode';
|
export * from './reqUpdateSupplierCode';
|
||||||
|
export * from './reqUpdateSupplierCustom';
|
||||||
|
export * from './reqUpdateSupplierCustomAnyOf';
|
||||||
export * from './reqUpdateSupplierManagerContactNumber';
|
export * from './reqUpdateSupplierManagerContactNumber';
|
||||||
export * from './reqUpdateSupplierManagerEmail';
|
export * from './reqUpdateSupplierManagerEmail';
|
||||||
export * from './reqUpdateSupplierManagerName';
|
export * from './reqUpdateSupplierManagerName';
|
||||||
@ -239,6 +253,10 @@ export * from './resCardListMsg';
|
|||||||
export * from './resCardMsg';
|
export * from './resCardMsg';
|
||||||
export * from './resCheckCodes';
|
export * from './resCheckCodes';
|
||||||
export * from './resCheckCodesMsg';
|
export * from './resCheckCodesMsg';
|
||||||
|
export * from './resCompanySettings';
|
||||||
|
export * from './resCompanySettingsMsg';
|
||||||
|
export * from './resCompanySettingsSettings';
|
||||||
|
export * from './resCompanySettingsSettingsAnyOf';
|
||||||
export * from './resCompanyUser';
|
export * from './resCompanyUser';
|
||||||
export * from './resCompanyUserList';
|
export * from './resCompanyUserList';
|
||||||
export * from './resCompanyUserListMsg';
|
export * from './resCompanyUserListMsg';
|
||||||
@ -361,6 +379,8 @@ export * from './sessionData';
|
|||||||
export * from './sessionDataAnchoringPrice';
|
export * from './sessionDataAnchoringPrice';
|
||||||
export * from './sessionDataBidAt';
|
export * from './sessionDataBidAt';
|
||||||
export * from './sessionDataBidPrice';
|
export * from './sessionDataBidPrice';
|
||||||
|
export * from './sessionDataCustom';
|
||||||
|
export * from './sessionDataCustomAnyOf';
|
||||||
export * from './sessionDataEmailSentAt';
|
export * from './sessionDataEmailSentAt';
|
||||||
export * from './sessionDataRejectDeliveryType';
|
export * from './sessionDataRejectDeliveryType';
|
||||||
export * from './sessionDataRejectPrice';
|
export * from './sessionDataRejectPrice';
|
||||||
@ -369,6 +389,7 @@ export * from './sessionStatus';
|
|||||||
export * from './statCardUsage';
|
export * from './statCardUsage';
|
||||||
export * from './statCategory';
|
export * from './statCategory';
|
||||||
export * from './statKpi';
|
export * from './statKpi';
|
||||||
|
export * from './statMarkupPoint';
|
||||||
export * from './statMonthPoint';
|
export * from './statMonthPoint';
|
||||||
export * from './statOutcome';
|
export * from './statOutcome';
|
||||||
export * from './statParticipation';
|
export * from './statParticipation';
|
||||||
@ -382,6 +403,8 @@ export * from './supplierDataAccountStatus';
|
|||||||
export * from './supplierDataCode';
|
export * from './supplierDataCode';
|
||||||
export * from './supplierDataCreatedAt';
|
export * from './supplierDataCreatedAt';
|
||||||
export * from './supplierDataCreatorName';
|
export * from './supplierDataCreatorName';
|
||||||
|
export * from './supplierDataCustom';
|
||||||
|
export * from './supplierDataCustomAnyOf';
|
||||||
export * from './supplierDataManagerContactNumber';
|
export * from './supplierDataManagerContactNumber';
|
||||||
export * from './supplierDataManagerEmail';
|
export * from './supplierDataManagerEmail';
|
||||||
export * from './supplierDataManagerName';
|
export * from './supplierDataManagerName';
|
||||||
@ -389,7 +412,9 @@ export * from './supplierDataTotalRevenue';
|
|||||||
export * from './supplierDataUpdatedAt';
|
export * from './supplierDataUpdatedAt';
|
||||||
export * from './supplierItemData';
|
export * from './supplierItemData';
|
||||||
export * from './supplierItemDataCreatedAt';
|
export * from './supplierItemDataCreatedAt';
|
||||||
|
export * from './supplierItemDataItemCategory';
|
||||||
export * from './supplierItemDataItemCode';
|
export * from './supplierItemDataItemCode';
|
||||||
|
export * from './supplierItemDataItemManufacturer';
|
||||||
export * from './supplierItemDataUpdatedAt';
|
export * from './supplierItemDataUpdatedAt';
|
||||||
export * from './targetCandidate';
|
export * from './targetCandidate';
|
||||||
export * from './userRole';
|
export * from './userRole';
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import type { ItemDataQuantityUnit } from './itemDataQuantityUnit';
|
|||||||
import type { ItemDataDeliveryType } from './itemDataDeliveryType';
|
import type { ItemDataDeliveryType } from './itemDataDeliveryType';
|
||||||
import type { ItemDataVatYn } from './itemDataVatYn';
|
import type { ItemDataVatYn } from './itemDataVatYn';
|
||||||
import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn';
|
import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn';
|
||||||
|
import type { ItemDataCustom } from './itemDataCustom';
|
||||||
import type { ItemDataCreatedAt } from './itemDataCreatedAt';
|
import type { ItemDataCreatedAt } from './itemDataCreatedAt';
|
||||||
import type { ItemDataUpdatedAt } from './itemDataUpdatedAt';
|
import type { ItemDataUpdatedAt } from './itemDataUpdatedAt';
|
||||||
|
|
||||||
@ -50,6 +51,8 @@ export interface ItemData {
|
|||||||
delivery_type?: ItemDataDeliveryType;
|
delivery_type?: ItemDataDeliveryType;
|
||||||
vat_yn?: ItemDataVatYn;
|
vat_yn?: ItemDataVatYn;
|
||||||
delivery_fee_yn?: ItemDataDeliveryFeeYn;
|
delivery_fee_yn?: ItemDataDeliveryFeeYn;
|
||||||
|
custom?: ItemDataCustom;
|
||||||
|
supplier_names?: string[];
|
||||||
created_at?: ItemDataCreatedAt;
|
created_at?: ItemDataCreatedAt;
|
||||||
updated_at?: ItemDataUpdatedAt;
|
updated_at?: ItemDataUpdatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
9
negodata/front/src/api/generated/model/itemDataCustom.ts
Normal file
9
negodata/front/src/api/generated/model/itemDataCustom.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ItemDataCustomAnyOf } from './itemDataCustomAnyOf';
|
||||||
|
|
||||||
|
export type ItemDataCustom = ItemDataCustomAnyOf | 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 ItemDataCustomAnyOf = { [key: string]: unknown };
|
||||||
@ -4,8 +4,12 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { ItemSupplyTypeSupplierName } from './itemSupplyTypeSupplierName';
|
||||||
|
import type { ItemSupplyTypeSupplierItemId } from './itemSupplyTypeSupplierItemId';
|
||||||
|
|
||||||
export interface ItemSupplyType {
|
export interface ItemSupplyType {
|
||||||
supplier_id: string;
|
supplier_id: string;
|
||||||
supply_type: number;
|
supply_type: number;
|
||||||
|
supplier_name?: ItemSupplyTypeSupplierName;
|
||||||
|
supplier_item_id?: ItemSupplyTypeSupplierItemId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ItemSupplyTypeSupplierItemId = 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 ItemSupplyTypeSupplierName = string | null;
|
||||||
@ -21,6 +21,7 @@ import type { ReqCreateItemQuantityUnit } from './reqCreateItemQuantityUnit';
|
|||||||
import type { ReqCreateItemDeliveryType } from './reqCreateItemDeliveryType';
|
import type { ReqCreateItemDeliveryType } from './reqCreateItemDeliveryType';
|
||||||
import type { ReqCreateItemVatYn } from './reqCreateItemVatYn';
|
import type { ReqCreateItemVatYn } from './reqCreateItemVatYn';
|
||||||
import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn';
|
import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn';
|
||||||
|
import type { ReqCreateItemCustom } from './reqCreateItemCustom';
|
||||||
|
|
||||||
export interface ReqCreateItem {
|
export interface ReqCreateItem {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -43,4 +44,5 @@ export interface ReqCreateItem {
|
|||||||
delivery_type?: ReqCreateItemDeliveryType;
|
delivery_type?: ReqCreateItemDeliveryType;
|
||||||
vat_yn?: ReqCreateItemVatYn;
|
vat_yn?: ReqCreateItemVatYn;
|
||||||
delivery_fee_yn?: ReqCreateItemDeliveryFeeYn;
|
delivery_fee_yn?: ReqCreateItemDeliveryFeeYn;
|
||||||
|
custom?: ReqCreateItemCustom;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ReqCreateItemCustomAnyOf } from './reqCreateItemCustomAnyOf';
|
||||||
|
|
||||||
|
export type ReqCreateItemCustom = ReqCreateItemCustomAnyOf | 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 ReqCreateItemCustomAnyOf = { [key: string]: unknown };
|
||||||
@ -9,6 +9,7 @@ import type { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerNam
|
|||||||
import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
|
import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
|
||||||
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
|
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
|
||||||
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
|
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
|
||||||
|
import type { ReqCreateSupplierCustom } from './reqCreateSupplierCustom';
|
||||||
|
|
||||||
export interface ReqCreateSupplier {
|
export interface ReqCreateSupplier {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -17,4 +18,5 @@ export interface ReqCreateSupplier {
|
|||||||
manager_email?: ReqCreateSupplierManagerEmail;
|
manager_email?: ReqCreateSupplierManagerEmail;
|
||||||
manager_contact_number?: ReqCreateSupplierManagerContactNumber;
|
manager_contact_number?: ReqCreateSupplierManagerContactNumber;
|
||||||
total_revenue?: ReqCreateSupplierTotalRevenue;
|
total_revenue?: ReqCreateSupplierTotalRevenue;
|
||||||
|
custom?: ReqCreateSupplierCustom;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ReqCreateSupplierCustomAnyOf } from './reqCreateSupplierCustomAnyOf';
|
||||||
|
|
||||||
|
export type ReqCreateSupplierCustom = ReqCreateSupplierCustomAnyOf | 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 ReqCreateSupplierCustomAnyOf = { [key: string]: unknown };
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ReqUpdateCompanySettingsSettings } from './reqUpdateCompanySettingsSettings';
|
||||||
|
|
||||||
|
export interface ReqUpdateCompanySettings {
|
||||||
|
settings?: ReqUpdateCompanySettingsSettings;
|
||||||
|
}
|
||||||
@ -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 ReqUpdateCompanySettingsSettings = { [key: string]: unknown };
|
||||||
@ -24,6 +24,7 @@ import type { ReqUpdateItemQuantityUnit } from './reqUpdateItemQuantityUnit';
|
|||||||
import type { ReqUpdateItemDeliveryType } from './reqUpdateItemDeliveryType';
|
import type { ReqUpdateItemDeliveryType } from './reqUpdateItemDeliveryType';
|
||||||
import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn';
|
import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn';
|
||||||
import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn';
|
import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn';
|
||||||
|
import type { ReqUpdateItemCustom } from './reqUpdateItemCustom';
|
||||||
|
|
||||||
export interface ReqUpdateItem {
|
export interface ReqUpdateItem {
|
||||||
name?: ReqUpdateItemName;
|
name?: ReqUpdateItemName;
|
||||||
@ -46,4 +47,5 @@ export interface ReqUpdateItem {
|
|||||||
delivery_type?: ReqUpdateItemDeliveryType;
|
delivery_type?: ReqUpdateItemDeliveryType;
|
||||||
vat_yn?: ReqUpdateItemVatYn;
|
vat_yn?: ReqUpdateItemVatYn;
|
||||||
delivery_fee_yn?: ReqUpdateItemDeliveryFeeYn;
|
delivery_fee_yn?: ReqUpdateItemDeliveryFeeYn;
|
||||||
|
custom?: ReqUpdateItemCustom;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ReqUpdateItemCustomAnyOf } from './reqUpdateItemCustomAnyOf';
|
||||||
|
|
||||||
|
export type ReqUpdateItemCustom = ReqUpdateItemCustomAnyOf | 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 ReqUpdateItemCustomAnyOf = { [key: string]: unknown };
|
||||||
@ -10,6 +10,7 @@ import type { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerNam
|
|||||||
import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
|
import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
|
||||||
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
|
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
|
||||||
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
|
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
|
||||||
|
import type { ReqUpdateSupplierCustom } from './reqUpdateSupplierCustom';
|
||||||
|
|
||||||
export interface ReqUpdateSupplier {
|
export interface ReqUpdateSupplier {
|
||||||
name?: ReqUpdateSupplierName;
|
name?: ReqUpdateSupplierName;
|
||||||
@ -18,4 +19,5 @@ export interface ReqUpdateSupplier {
|
|||||||
manager_email?: ReqUpdateSupplierManagerEmail;
|
manager_email?: ReqUpdateSupplierManagerEmail;
|
||||||
manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
|
manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
|
||||||
total_revenue?: ReqUpdateSupplierTotalRevenue;
|
total_revenue?: ReqUpdateSupplierTotalRevenue;
|
||||||
|
custom?: ReqUpdateSupplierCustom;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ReqUpdateSupplierCustomAnyOf } from './reqUpdateSupplierCustomAnyOf';
|
||||||
|
|
||||||
|
export type ReqUpdateSupplierCustom = ReqUpdateSupplierCustomAnyOf | 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 ReqUpdateSupplierCustomAnyOf = { [key: string]: unknown };
|
||||||
15
negodata/front/src/api/generated/model/resCompanySettings.ts
Normal file
15
negodata/front/src/api/generated/model/resCompanySettings.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 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 { ResCompanySettingsMsg } from './resCompanySettingsMsg';
|
||||||
|
import type { ResCompanySettingsSettings } from './resCompanySettingsSettings';
|
||||||
|
|
||||||
|
export interface ResCompanySettings {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResCompanySettingsMsg;
|
||||||
|
settings?: ResCompanySettingsSettings;
|
||||||
|
}
|
||||||
@ -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 ResCompanySettingsMsg = string | null;
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ResCompanySettingsSettingsAnyOf } from './resCompanySettingsSettingsAnyOf';
|
||||||
|
|
||||||
|
export type ResCompanySettingsSettings = ResCompanySettingsSettingsAnyOf | 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 ResCompanySettingsSettingsAnyOf = { [key: string]: unknown };
|
||||||
@ -13,6 +13,7 @@ import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
|||||||
import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
|
import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
|
||||||
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
|
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
|
||||||
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
|
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
|
||||||
|
import type { SessionDataCustom } from './sessionDataCustom';
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@ -32,5 +33,6 @@ export interface SessionData {
|
|||||||
reject_price?: SessionDataRejectPrice;
|
reject_price?: SessionDataRejectPrice;
|
||||||
reject_delivery_type?: SessionDataRejectDeliveryType;
|
reject_delivery_type?: SessionDataRejectDeliveryType;
|
||||||
email_sent_at?: SessionDataEmailSentAt;
|
email_sent_at?: SessionDataEmailSentAt;
|
||||||
|
custom?: SessionDataCustom;
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { SessionDataCustomAnyOf } from './sessionDataCustomAnyOf';
|
||||||
|
|
||||||
|
export type SessionDataCustom = SessionDataCustomAnyOf | 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 SessionDataCustomAnyOf = { [key: string]: unknown };
|
||||||
@ -13,4 +13,5 @@ export interface StatKpi {
|
|||||||
savings_delta_mom?: number;
|
savings_delta_mom?: number;
|
||||||
closed_count?: number;
|
closed_count?: number;
|
||||||
regen_avg_round?: number;
|
regen_avg_round?: number;
|
||||||
|
markup_suppression_rate?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
11
negodata/front/src/api/generated/model/statMarkupPoint.ts
Normal file
11
negodata/front/src/api/generated/model/statMarkupPoint.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StatMarkupPoint {
|
||||||
|
month: string;
|
||||||
|
rate?: number;
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { StatKpi } from './statKpi';
|
import type { StatKpi } from './statKpi';
|
||||||
import type { StatMonthPoint } from './statMonthPoint';
|
import type { StatMonthPoint } from './statMonthPoint';
|
||||||
|
import type { StatMarkupPoint } from './statMarkupPoint';
|
||||||
import type { StatOutcome } from './statOutcome';
|
import type { StatOutcome } from './statOutcome';
|
||||||
import type { StatParticipation } from './statParticipation';
|
import type { StatParticipation } from './statParticipation';
|
||||||
import type { StatTypeRow } from './statTypeRow';
|
import type { StatTypeRow } from './statTypeRow';
|
||||||
@ -15,6 +16,7 @@ import type { StatCardUsage } from './statCardUsage';
|
|||||||
export interface StatScope {
|
export interface StatScope {
|
||||||
kpi?: StatKpi;
|
kpi?: StatKpi;
|
||||||
trend?: StatMonthPoint[];
|
trend?: StatMonthPoint[];
|
||||||
|
markup_trend?: StatMarkupPoint[];
|
||||||
outcome?: StatOutcome;
|
outcome?: StatOutcome;
|
||||||
participation?: StatParticipation;
|
participation?: StatParticipation;
|
||||||
type_split?: StatTypeRow[];
|
type_split?: StatTypeRow[];
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { SupplierDataManagerName } from './supplierDataManagerName';
|
|||||||
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
|
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
|
||||||
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
|
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
|
||||||
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
|
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
|
||||||
|
import type { SupplierDataCustom } from './supplierDataCustom';
|
||||||
import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId';
|
import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId';
|
||||||
import type { SupplierDataAccountStatus } from './supplierDataAccountStatus';
|
import type { SupplierDataAccountStatus } from './supplierDataAccountStatus';
|
||||||
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
|
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
|
||||||
@ -26,6 +27,7 @@ export interface SupplierData {
|
|||||||
manager_email?: SupplierDataManagerEmail;
|
manager_email?: SupplierDataManagerEmail;
|
||||||
manager_contact_number?: SupplierDataManagerContactNumber;
|
manager_contact_number?: SupplierDataManagerContactNumber;
|
||||||
total_revenue?: SupplierDataTotalRevenue;
|
total_revenue?: SupplierDataTotalRevenue;
|
||||||
|
custom?: SupplierDataCustom;
|
||||||
account_login_id?: SupplierDataAccountLoginId;
|
account_login_id?: SupplierDataAccountLoginId;
|
||||||
account_status?: SupplierDataAccountStatus;
|
account_status?: SupplierDataAccountStatus;
|
||||||
created_at?: SupplierDataCreatedAt;
|
created_at?: SupplierDataCreatedAt;
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { SupplierDataCustomAnyOf } from './supplierDataCustomAnyOf';
|
||||||
|
|
||||||
|
export type SupplierDataCustom = SupplierDataCustomAnyOf | 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 SupplierDataCustomAnyOf = { [key: string]: unknown };
|
||||||
@ -5,6 +5,8 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { SupplierItemDataItemCode } from './supplierItemDataItemCode';
|
import type { SupplierItemDataItemCode } from './supplierItemDataItemCode';
|
||||||
|
import type { SupplierItemDataItemCategory } from './supplierItemDataItemCategory';
|
||||||
|
import type { SupplierItemDataItemManufacturer } from './supplierItemDataItemManufacturer';
|
||||||
import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt';
|
import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt';
|
||||||
import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt';
|
import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt';
|
||||||
|
|
||||||
@ -14,6 +16,8 @@ export interface SupplierItemData {
|
|||||||
item_name: string;
|
item_name: string;
|
||||||
item_code?: SupplierItemDataItemCode;
|
item_code?: SupplierItemDataItemCode;
|
||||||
supply_type: number;
|
supply_type: number;
|
||||||
|
item_category?: SupplierItemDataItemCategory;
|
||||||
|
item_manufacturer?: SupplierItemDataItemManufacturer;
|
||||||
created_at?: SupplierItemDataCreatedAt;
|
created_at?: SupplierItemDataCreatedAt;
|
||||||
updated_at?: SupplierItemDataUpdatedAt;
|
updated_at?: SupplierItemDataUpdatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 SupplierItemDataItemCategory = 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 SupplierItemDataItemManufacturer = string | null;
|
||||||
@ -13,6 +13,7 @@ import PartnersPage from '../pages/partners';
|
|||||||
import QuotationPage from '../pages/quotation';
|
import QuotationPage from '../pages/quotation';
|
||||||
import CardsPage from '../pages/cards';
|
import CardsPage from '../pages/cards';
|
||||||
import MembersPage from '../pages/members';
|
import MembersPage from '../pages/members';
|
||||||
|
import SettingsPage from '../pages/settings';
|
||||||
import NotificationsPage from '../pages/notifications';
|
import NotificationsPage from '../pages/notifications';
|
||||||
import OnboardingPage from '../pages/onboarding';
|
import OnboardingPage from '../pages/onboarding';
|
||||||
|
|
||||||
@ -88,6 +89,12 @@ export const router = createBrowserRouter([
|
|||||||
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
||||||
Component: MembersPage,
|
Component: MembersPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정.
|
||||||
|
path: 'settings',
|
||||||
|
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
||||||
|
Component: SettingsPage,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
|||||||
QUOTATION: '/quotation',
|
QUOTATION: '/quotation',
|
||||||
CARDS: '/cards',
|
CARDS: '/cards',
|
||||||
MEMBERS: '/members',
|
MEMBERS: '/members',
|
||||||
|
SETTINGS: '/settings',
|
||||||
NOTIFICATIONS: '/notifications',
|
NOTIFICATIONS: '/notifications',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, type ReactNode, type ElementType } from 'react';
|
import { useEffect, useState, type ReactNode, type ElementType } from 'react';
|
||||||
import { PageType } from '@/types';
|
import { PageType } from '@/types';
|
||||||
import { useAuth } from '@/features/auth/useAuth';
|
import { useAuth } from '@/features/auth/useAuth';
|
||||||
|
import { useBranding } from '@/features/settings/useCompanySettings';
|
||||||
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
|
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -58,7 +59,10 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '관리',
|
label: '관리',
|
||||||
items: [{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }],
|
items: [
|
||||||
|
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true },
|
||||||
|
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', ownerOnly: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -72,11 +76,13 @@ const pageLabelMap: Record<PageType, string> = {
|
|||||||
QUOTATION: '견적관리',
|
QUOTATION: '견적관리',
|
||||||
CARDS: '협상카드관리',
|
CARDS: '협상카드관리',
|
||||||
MEMBERS: '회원관리',
|
MEMBERS: '회원관리',
|
||||||
|
SETTINGS: '회사 설정',
|
||||||
NOTIFICATIONS: '알림',
|
NOTIFICATIONS: '알림',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
|
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const branding = useBranding(); // 회사 설정 브랜딩(서비스명/로고). 미설정 시 기본 NegoData
|
||||||
// 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD
|
// 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD
|
||||||
const today = new Date().toLocaleDateString('sv-SE');
|
const today = new Date().toLocaleDateString('sv-SE');
|
||||||
|
|
||||||
@ -142,8 +148,16 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
|||||||
<div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border">
|
<div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border">
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
|
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
|
||||||
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
|
{branding.logoUrl ? (
|
||||||
NegoData
|
<img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-4 rounded-[5px] bg-primary"
|
||||||
|
style={branding.primaryColor ? { backgroundColor: branding.primaryColor } : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{branding.serviceName}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,8 @@ 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 } from '@/stores/auth';
|
||||||
|
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||||
|
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||||
import { SupplierAccountManager } from './SupplierAccountManager';
|
import { SupplierAccountManager } from './SupplierAccountManager';
|
||||||
import { type Partner } from '../types';
|
import { type Partner } from '../types';
|
||||||
@ -83,6 +85,11 @@ export function PartnerFormSheet({
|
|||||||
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
||||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||||
|
|
||||||
|
// 회사 협력사 커스텀필드(정의=companies.settings.supplier_fields, 값=suppliers.custom)
|
||||||
|
const { settings } = useCompanySettings();
|
||||||
|
const supplierFields = settings.supplier_fields ?? [];
|
||||||
|
const customValues = useCustomFieldValues(supplierFields, partner?.custom);
|
||||||
|
|
||||||
const onValid = async (v: FormValues) => {
|
const onValid = async (v: FormValues) => {
|
||||||
const common = {
|
const common = {
|
||||||
name: v.name,
|
name: v.name,
|
||||||
@ -91,6 +98,7 @@ export function PartnerFormSheet({
|
|||||||
manager_email: v.managerEmail,
|
manager_email: v.managerEmail,
|
||||||
manager_contact_number: v.managerPhone,
|
manager_contact_number: v.managerPhone,
|
||||||
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
|
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
|
||||||
|
...(supplierFields.length > 0 ? { custom: customValues.values } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
@ -213,6 +221,9 @@ export function PartnerFormSheet({
|
|||||||
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 커스텀 필드 — companies.settings.supplier_fields 정의대로 렌더, suppliers.custom 에 저장 */}
|
||||||
|
<CustomFieldInputs fields={supplierFields} state={customValues} title="회사 추가 항목" />
|
||||||
|
|
||||||
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||||
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import type { Partner } from '../types';
|
|||||||
|
|
||||||
type PartnerTableProps = {
|
type PartnerTableProps = {
|
||||||
data: Partner[];
|
data: Partner[];
|
||||||
|
selectedIds: string[];
|
||||||
|
onSelectionChange: (ids: string[]) => void;
|
||||||
onRowClick: (part: Partner) => void;
|
onRowClick: (part: Partner) => void;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -15,6 +17,8 @@ type PartnerTableProps = {
|
|||||||
|
|
||||||
export function PartnerTable({
|
export function PartnerTable({
|
||||||
data,
|
data,
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
page,
|
page,
|
||||||
totalPages,
|
totalPages,
|
||||||
@ -27,6 +31,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 }}
|
||||||
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
|
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 분류카테고리 파생(IMK #17, B안) — 취급상품에서 (카테고리-제조원, 공급유형) distinct 집계.
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 border-t border-border space-y-2">
|
<div className="pt-4 border-t border-border space-y-2">
|
||||||
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
|
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
|
||||||
@ -110,7 +111,9 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
|||||||
items.map((m) => (
|
items.map((m) => (
|
||||||
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
|
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Typography as="span" variant="small" className="font-semibold block truncate">{m.item_name}</Typography>
|
<Typography as="span" variant="small" className="font-semibold block truncate">
|
||||||
|
{m.item_category ? `${m.item_category} - ${m.item_name}` : m.item_name}
|
||||||
|
</Typography>
|
||||||
{m.item_code && (
|
{m.item_code && (
|
||||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
|
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { customFetch } from '@/api/mutator/custom-fetch';
|
|||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
|
||||||
|
import type { CustomFieldDef } from '@/features/settings/catalog';
|
||||||
import type { Product } from '../types';
|
import type { Product } from '../types';
|
||||||
|
|
||||||
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다.
|
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다.
|
||||||
@ -29,20 +31,16 @@ type RawRow = {
|
|||||||
moq: string;
|
moq: string;
|
||||||
lead_time: number;
|
lead_time: number;
|
||||||
quantity_unit: string;
|
quantity_unit: string;
|
||||||
delivery_type: string; // 한글 라벨로 입력 → 전송 시 코드 변환
|
delivery_type: string; // 라벨로 입력 → 전송 시 코드 변환
|
||||||
vat_yn: string; // Y/N
|
vat_yn: string; // Y/N
|
||||||
delivery_fee_yn: string; // Y/N
|
delivery_fee_yn: string; // Y/N
|
||||||
|
custom: Record<string, string>; // 회사 커스텀필드(item_fields) 값 — 전송 시 타입 변환
|
||||||
};
|
};
|
||||||
|
|
||||||
// 검증 결과가 붙은 행. UI는 이걸 그린다.
|
// 검증 결과가 붙은 행. UI는 이걸 그린다.
|
||||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||||
|
|
||||||
// 배송형태 라벨 ↔ delivery_type 코드(공용 enum 과 동일 집합)
|
type LabelFn = (key: string) => string;
|
||||||
const DELIVERY_LABEL_TO_CODE: Record<string, number> = {
|
|
||||||
협력사배송: 1,
|
|
||||||
지정택배배송: 2,
|
|
||||||
픽업배송: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true)
|
// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true)
|
||||||
const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim());
|
const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim());
|
||||||
@ -51,31 +49,71 @@ const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.
|
|||||||
const isYnToken = (s: string): boolean =>
|
const isYnToken = (s: string): boolean =>
|
||||||
/^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim());
|
/^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim());
|
||||||
|
|
||||||
// 업로드 양식(.csv) 컬럼 정의 — 헤더 ↔ RawRow 필드. 양식/예시/파싱이 이 한 곳을 공유한다.
|
// 표준 컬럼(고정 18개 — 삭제 없음). labelKey 가 있으면 헤더를 회사 설정 용어로 치환하고,
|
||||||
const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [
|
// base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다.
|
||||||
{ header: '상품명', key: 'name' },
|
const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [
|
||||||
{ header: '상품코드', key: 'code' },
|
{ base: '상품명', key: 'name' },
|
||||||
{ header: '모델번호', key: 'model_name' },
|
{ base: '상품코드', key: 'code' },
|
||||||
{ header: '카테고리', key: 'category' },
|
{ base: '모델번호', key: 'model_name' },
|
||||||
{ header: '규격', key: 'spec' },
|
{ base: '카테고리', key: 'category', labelKey: 'category' },
|
||||||
{ header: '제조사', key: 'manufacturer' },
|
{ base: '규격', key: 'spec' },
|
||||||
{ header: '원산지', key: 'made_in' },
|
{ base: '제조사', key: 'manufacturer' },
|
||||||
{ header: '상품 단가', key: 'price' },
|
{ base: '원산지', key: 'made_in' },
|
||||||
{ header: '최저한도', key: 'minPrice' },
|
{ base: '상품 단가', key: 'price', labelKey: 'item.price' },
|
||||||
{ header: '매입가', key: 'purchase_price' },
|
{ base: '최저한도', key: 'minPrice' },
|
||||||
{ header: '판매가', key: 'selling_price' },
|
{ base: '매입가', key: 'purchase_price' },
|
||||||
{ header: '이미지URL', key: 'image_url' },
|
{ base: '판매가', key: 'selling_price' },
|
||||||
{ header: '최소주문수량', key: 'moq' },
|
{ base: '이미지URL', key: 'image_url' },
|
||||||
{ header: '리드타임(일)', key: 'lead_time' },
|
{ base: '최소주문수량', key: 'moq' },
|
||||||
{ header: '단위', key: 'quantity_unit' },
|
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' },
|
||||||
{ header: '배송형태', key: 'delivery_type' },
|
{ base: '단위', key: 'quantity_unit' },
|
||||||
{ header: '부가세포함(Y/N)', key: 'vat_yn' },
|
{ base: '배송형태', key: 'delivery_type' },
|
||||||
{ header: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
|
{ base: '부가세포함(Y/N)', key: 'vat_yn' },
|
||||||
|
{ base: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기)
|
// 렌더/파싱/양식이 공유하는 컬럼(헤더는 회사 설정 반영). customKey 있으면 커스텀필드 컬럼.
|
||||||
const NUMERIC_KEYS = new Set<keyof RawRow>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
|
type UploadColumn = {
|
||||||
const REQUIRED_KEYS = new Set<keyof RawRow>(['name', 'code', 'price']);
|
header: string;
|
||||||
|
aliases: string[]; // 파싱 시 인식할 헤더 후보(회사 라벨 + 기본 헤더)
|
||||||
|
key?: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>;
|
||||||
|
customKey?: string;
|
||||||
|
numeric: boolean;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUMERIC_KEYS = new Set<string>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
|
||||||
|
const REQUIRED_KEYS = new Set<string>(['name', 'code', 'price']);
|
||||||
|
|
||||||
|
// 회사 설정(용어 라벨 + item_fields 커스텀필드)으로 업로드 컬럼을 만든다.
|
||||||
|
// 기존 18컬럼은 전부 유지, 커스텀필드는 뒤에 추가된다.
|
||||||
|
function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColumn[] {
|
||||||
|
const standard: UploadColumn[] = STANDARD_COLUMNS.map((c) => {
|
||||||
|
const header = c.labelKey ? `${label(c.labelKey)}${c.suffix ?? ''}` : c.base;
|
||||||
|
return {
|
||||||
|
header,
|
||||||
|
aliases: [...new Set([header, c.base])],
|
||||||
|
key: c.key,
|
||||||
|
numeric: NUMERIC_KEYS.has(c.key),
|
||||||
|
required: REQUIRED_KEYS.has(c.key),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const custom: UploadColumn[] = itemFields.map((f) => ({
|
||||||
|
header: f.type === 'boolean' ? `${f.label}(Y/N)` : f.label,
|
||||||
|
aliases: [f.type === 'boolean' ? `${f.label}(Y/N)` : f.label, f.label],
|
||||||
|
customKey: f.key,
|
||||||
|
numeric: f.type === 'number',
|
||||||
|
required: false,
|
||||||
|
}));
|
||||||
|
return [...standard, ...custom];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다.
|
||||||
|
function buildDeliveryMap(label: LabelFn): Record<string, number> {
|
||||||
|
const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 };
|
||||||
|
for (const code of [1, 2, 3]) map[label(`delivery_type.${code}`)] = code;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다.
|
// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다.
|
||||||
const EXAMPLE_ROWS: Record<string, string | number>[] = [
|
const EXAMPLE_ROWS: Record<string, string | number>[] = [
|
||||||
@ -83,23 +121,32 @@ const EXAMPLE_ROWS: Record<string, string | number>[] = [
|
|||||||
name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A',
|
name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A',
|
||||||
category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국',
|
category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국',
|
||||||
price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, image_url: 'https://example.com/img/lfp-100a.jpg',
|
price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, image_url: 'https://example.com/img/lfp-100a.jpg',
|
||||||
moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: '협력사배송',
|
moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: 1,
|
||||||
vat_yn: 'Y', delivery_fee_yn: 'N',
|
vat_yn: 'Y', delivery_fee_yn: 'N',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A',
|
name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A',
|
||||||
category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본',
|
category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본',
|
||||||
price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, image_url: 'https://example.com/img/rx-6a.jpg',
|
price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, image_url: 'https://example.com/img/rx-6a.jpg',
|
||||||
moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: '지정택배배송',
|
moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: 2,
|
||||||
vat_yn: 'Y', delivery_fee_yn: 'N',
|
vat_yn: 'Y', delivery_fee_yn: 'N',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. 툴바·모달이 공유한다.
|
// 업로드 양식(.csv) 다운로드 — 회사 설정 헤더(라벨 치환) + 커스텀필드 컬럼 + 예시 행.
|
||||||
export function downloadProductTemplate() {
|
// 파싱(buildColumns)과 같은 정의를 공유하므로 받은 양식이 그대로 다시 업로드된다. 툴바·모달이 공유한다.
|
||||||
|
export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldDef[]) {
|
||||||
|
const columns = buildColumns(label, itemFields);
|
||||||
downloadExcel<Record<string, string | number>>(
|
downloadExcel<Record<string, string | number>>(
|
||||||
'상품_업로드_양식',
|
'상품_업로드_양식',
|
||||||
UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })),
|
columns.map((c) => ({
|
||||||
|
header: c.header,
|
||||||
|
value: (r) => {
|
||||||
|
if (c.customKey) return c.numeric ? 10 : ''; // 커스텀 예시값(숫자=10, 그 외 빈칸)
|
||||||
|
if (c.key === 'delivery_type') return label(`delivery_type.${r.delivery_type}`); // 예시도 회사 라벨로
|
||||||
|
return r[c.key as string];
|
||||||
|
},
|
||||||
|
})),
|
||||||
EXAMPLE_ROWS,
|
EXAMPLE_ROWS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -117,6 +164,10 @@ function validateRows(
|
|||||||
rows: RawRow[],
|
rows: RawRow[],
|
||||||
products: Product[],
|
products: Product[],
|
||||||
serverErrors: Record<string, string>,
|
serverErrors: Record<string, string>,
|
||||||
|
deliveryMap: Record<string, number>,
|
||||||
|
deliveryLabels: string[],
|
||||||
|
priceLabel: string,
|
||||||
|
itemFields: CustomFieldDef[],
|
||||||
): ValidatedRow[] {
|
): ValidatedRow[] {
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
|
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
|
||||||
@ -128,12 +179,12 @@ function validateRows(
|
|||||||
const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code);
|
const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code);
|
||||||
if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.');
|
if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.');
|
||||||
|
|
||||||
if (row.price <= 0) return fail('유효성 위반 - 상품 단가는 0보다 커야 합니다.');
|
if (row.price <= 0) return fail(`유효성 위반 - ${priceLabel}는 0보다 커야 합니다.`);
|
||||||
if (row.minPrice > row.price) return fail('유효성 위반 - 최저 한도가 상품 단가보다 큽니다.');
|
if (row.minPrice > row.price) return fail(`유효성 위반 - 최저 한도가 ${priceLabel}보다 큽니다.`);
|
||||||
|
|
||||||
// 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL.
|
// 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL.
|
||||||
if (row.delivery_type.trim() && !(row.delivery_type.trim() in DELIVERY_LABEL_TO_CODE)) {
|
if (row.delivery_type.trim() && !(row.delivery_type.trim() in deliveryMap)) {
|
||||||
return fail('배송형태 - 협력사배송 / 지정택배배송 / 픽업배송 중 하나여야 합니다.');
|
return fail(`배송형태 - ${deliveryLabels.join(' / ')} 중 하나여야 합니다.`);
|
||||||
}
|
}
|
||||||
if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) {
|
if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) {
|
||||||
return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
|
return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
|
||||||
@ -144,6 +195,13 @@ function validateRows(
|
|||||||
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
|
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
|
||||||
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
|
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
|
||||||
}
|
}
|
||||||
|
// 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자.
|
||||||
|
for (const f of itemFields) {
|
||||||
|
const v = (row.custom[f.key] ?? '').trim();
|
||||||
|
if (!v) continue;
|
||||||
|
if (f.type === 'boolean' && !isYnToken(v)) return fail(`${f.label} - Y 또는 N(예/아니오)으로 입력해 주십시오.`);
|
||||||
|
if (f.type === 'number' && Number.isNaN(Number(v))) return fail(`${f.label} - 숫자로 입력해 주십시오.`);
|
||||||
|
}
|
||||||
|
|
||||||
// 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리.
|
// 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리.
|
||||||
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
|
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
|
||||||
@ -153,7 +211,14 @@ function validateRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 검증된(정상) 행 → 서버 생성 payload. 엑셀 업로드 공통 기본값 적용.
|
// 검증된(정상) 행 → 서버 생성 payload. 엑셀 업로드 공통 기본값 적용.
|
||||||
function toItemCreate(row: RawRow): ItemCreate {
|
function toItemCreate(row: RawRow, deliveryMap: Record<string, number>, itemFields: CustomFieldDef[]): ItemCreate {
|
||||||
|
// 커스텀필드 문자열 → 정의 타입대로 변환(빈 값은 제외)
|
||||||
|
const custom: Record<string, unknown> = {};
|
||||||
|
for (const f of itemFields) {
|
||||||
|
const v = (row.custom[f.key] ?? '').trim();
|
||||||
|
if (!v) continue;
|
||||||
|
custom[f.key] = f.type === 'number' ? Number(v) : f.type === 'boolean' ? parseYn(v) : v;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
@ -169,19 +234,27 @@ function toItemCreate(row: RawRow): ItemCreate {
|
|||||||
moq: row.moq || undefined,
|
moq: row.moq || undefined,
|
||||||
lead_time: row.lead_time || undefined,
|
lead_time: row.lead_time || undefined,
|
||||||
quantity_unit: row.quantity_unit || undefined,
|
quantity_unit: row.quantity_unit || undefined,
|
||||||
delivery_type: DELIVERY_LABEL_TO_CODE[row.delivery_type.trim()] ?? undefined,
|
delivery_type: deliveryMap[row.delivery_type.trim()] ?? undefined,
|
||||||
vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined,
|
vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined,
|
||||||
delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined,
|
delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined,
|
||||||
// minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일).
|
// minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일).
|
||||||
internet_lowest_price: row.minPrice,
|
internet_lowest_price: row.minPrice,
|
||||||
internet_lowest_price_yn: row.minPrice > 0,
|
internet_lowest_price_yn: row.minPrice > 0,
|
||||||
|
...(Object.keys(custom).length > 0 ? { custom } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고,
|
// 상품 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
|
||||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||||
|
// 컬럼(헤더 라벨·커스텀필드)은 회사 설정을 따른다 — 양식 다운로드와 동일 정의.
|
||||||
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
|
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
|
||||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||||
|
const label = useLabels();
|
||||||
|
const { settings } = useCompanySettings();
|
||||||
|
const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]);
|
||||||
|
const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]);
|
||||||
|
const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]);
|
||||||
|
|
||||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||||
const [rows, setRows] = useState<RawRow[]>([]);
|
const [rows, setRows] = useState<RawRow[]>([]);
|
||||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
||||||
@ -190,8 +263,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
|
|
||||||
// 파생: 검증 결과 + 카운트 (state로 저장하지 않음)
|
// 파생: 검증 결과 + 카운트 (state로 저장하지 않음)
|
||||||
const validated = useMemo(
|
const validated = useMemo(
|
||||||
() => validateRows(rows, products, serverErrors),
|
() => validateRows(
|
||||||
[rows, products, serverErrors],
|
rows, products, serverErrors, deliveryMap,
|
||||||
|
[1, 2, 3].map((c) => label(`delivery_type.${c}`)),
|
||||||
|
label('item.price'), itemFields,
|
||||||
|
),
|
||||||
|
[rows, products, serverErrors, deliveryMap, label, itemFields],
|
||||||
);
|
);
|
||||||
const validRows = validated.filter((r) => r.status === '정상');
|
const validRows = validated.filter((r) => r.status === '정상');
|
||||||
const validCount = validRows.length;
|
const validCount = validRows.length;
|
||||||
@ -206,31 +283,31 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생).
|
||||||
|
// 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases).
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
const parsed = parseCsv(await file.text());
|
const parsed = parseCsv(await file.text());
|
||||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
const pick = (r: Record<string, string>, c: UploadColumn): string => {
|
||||||
|
for (const a of c.aliases) if (r[a] !== undefined) return r[a];
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const loaded: RawRow[] = parsed.map((r, i) => {
|
||||||
|
const row: RawRow = {
|
||||||
id: `row-${i + 1}`,
|
id: `row-${i + 1}`,
|
||||||
rowNum: i + 2,
|
rowNum: i + 2,
|
||||||
name: r['상품명'] ?? '',
|
name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', made_in: '',
|
||||||
code: r['상품코드'] ?? '',
|
price: 0, minPrice: 0, purchase_price: 0, selling_price: 0,
|
||||||
model_name: r['모델번호'] ?? '',
|
image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '',
|
||||||
category: r['카테고리'] ?? '',
|
custom: {},
|
||||||
spec: r['규격'] ?? '',
|
};
|
||||||
manufacturer: r['제조사'] ?? '',
|
for (const c of columns) {
|
||||||
made_in: r['원산지'] ?? '',
|
const v = pick(r, c);
|
||||||
price: Number(r['상품 단가']) || 0,
|
if (c.customKey) row.custom[c.customKey] = v;
|
||||||
minPrice: Number(r['최저한도']) || 0,
|
else if (c.numeric) (row[c.key!] as number) = Number(v) || 0;
|
||||||
purchase_price: Number(r['매입가']) || 0,
|
else (row[c.key!] as string) = v;
|
||||||
selling_price: Number(r['판매가']) || 0,
|
}
|
||||||
image_url: r['이미지URL'] ?? '',
|
return row;
|
||||||
moq: r['최소주문수량'] ?? '',
|
});
|
||||||
lead_time: Number(r['리드타임(일)']) || 0,
|
|
||||||
quantity_unit: r['단위'] ?? '',
|
|
||||||
delivery_type: r['배송형태'] ?? '',
|
|
||||||
vat_yn: r['부가세포함(Y/N)'] ?? '',
|
|
||||||
delivery_fee_yn: r['배송비포함(Y/N)'] ?? '',
|
|
||||||
}));
|
|
||||||
setExcelFile(file.name);
|
setExcelFile(file.name);
|
||||||
setRows(loaded);
|
setRows(loaded);
|
||||||
setServerErrors({}); // 새 파일 → 직전 서버사유 초기화
|
setServerErrors({}); // 새 파일 → 직전 서버사유 초기화
|
||||||
@ -252,15 +329,14 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 숫자 컬럼은 정수화.
|
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 표준 숫자 컬럼은 정수화, 커스텀은 문자열 보관.
|
||||||
const handleUpdateField = (id: string, field: keyof RawRow, value: string) => {
|
const handleUpdateField = (id: string, c: UploadColumn, value: string) => {
|
||||||
setRows((cur) =>
|
setRows((cur) =>
|
||||||
cur.map((row) => {
|
cur.map((row) => {
|
||||||
if (row.id !== id) return row;
|
if (row.id !== id) return row;
|
||||||
if (NUMERIC_KEYS.has(field)) {
|
if (c.customKey) return { ...row, custom: { ...row.custom, [c.customKey]: value } };
|
||||||
return { ...row, [field]: Math.max(0, parseInt(value, 10) || 0) };
|
if (c.numeric) return { ...row, [c.key!]: Math.max(0, parseInt(value, 10) || 0) };
|
||||||
}
|
return { ...row, [c.key!]: value };
|
||||||
return { ...row, [field]: value };
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -276,7 +352,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const failures = await onConfirm(validRows.map(toItemCreate));
|
const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields)));
|
||||||
const okCount = validRows.length - failures.length;
|
const okCount = validRows.length - failures.length;
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
|
showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
|
||||||
@ -381,12 +457,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
|
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
|
||||||
<TableHead className="p-2 font-semibold text-center whitespace-nowrap">자격</TableHead>
|
<TableHead className="p-2 font-semibold text-center whitespace-nowrap">자격</TableHead>
|
||||||
<TableHead className="p-2 font-semibold whitespace-nowrap">진단 내용</TableHead>
|
<TableHead className="p-2 font-semibold whitespace-nowrap">진단 내용</TableHead>
|
||||||
{UPLOAD_COLUMNS.map((c) => (
|
{columns.map((c) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={c.key}
|
key={c.header}
|
||||||
className={`p-2 font-semibold whitespace-nowrap ${NUMERIC_KEYS.has(c.key) ? 'text-right' : ''}`}
|
className={`p-2 font-semibold whitespace-nowrap ${c.numeric ? 'text-right' : ''}`}
|
||||||
>
|
>
|
||||||
{c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''}
|
{c.header}{c.required ? ' *' : ''}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -417,16 +493,16 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
<TableCell className={`p-2 font-mono text-[10px] whitespace-nowrap ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
|
<TableCell className={`p-2 font-mono text-[10px] whitespace-nowrap ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
|
||||||
{row.message}
|
{row.message}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{UPLOAD_COLUMNS.map((c) => {
|
{columns.map((c) => {
|
||||||
const isNum = NUMERIC_KEYS.has(c.key);
|
const value = c.customKey ? row.custom[c.customKey] ?? '' : row[c.key!];
|
||||||
return (
|
return (
|
||||||
<TableCell key={c.key} className={`p-2 ${isNum ? 'text-right' : ''}`}>
|
<TableCell key={c.header} className={`p-2 ${c.numeric ? 'text-right' : ''}`}>
|
||||||
<Input
|
<Input
|
||||||
type={isNum ? 'number' : 'text'}
|
type={c.numeric && !c.customKey ? 'number' : 'text'}
|
||||||
placeholder={c.header}
|
placeholder={c.header}
|
||||||
className={`bg-muted/20 hover:bg-muted/50 ${isNum ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
|
className={`bg-muted/20 hover:bg-muted/50 ${c.numeric ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
|
||||||
value={String(row[c.key] ?? '')}
|
value={String(value ?? '')}
|
||||||
onChange={(e) => handleUpdateField(row.id, c.key, e.target.value)}
|
onChange={(e) => handleUpdateField(row.id, c, e.target.value)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||||
|
import { SUPPLIER_TYPE_OPTIONS, SupplierType, supplierTypeLabel } from '@/lib/enumLabels';
|
||||||
|
import { showToast } from '@/lib/notify';
|
||||||
|
import { useItemSuppliers } from '../hooks/useItemSuppliers';
|
||||||
|
|
||||||
|
// 상품 상세의 공급사 관리 섹션(IMK #20) — SupplierItemsManager(협력사측)의 상품측 미러.
|
||||||
|
// 공급사 추가/삭제 + 공급유형(제조/유통/총판/없음) 수정. 각 조작은 즉시 서버 반영(상품 기본정보 저장과 독립).
|
||||||
|
export function ItemSuppliersManager({ itemId }: { itemId: string }) {
|
||||||
|
const { suppliers, isLoading, addSupplier, changeType, removeSupplier } = useItemSuppliers(itemId);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const catalogQuery = useListSuppliers({ search: q || undefined, size: 30 }); // 서버검색
|
||||||
|
|
||||||
|
const [pickSupplierId, setPickSupplierId] = useState('');
|
||||||
|
const [pickLabel, setPickLabel] = useState('');
|
||||||
|
const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0)
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const mappedIds = new Set(suppliers.map((m) => m.supplier_id));
|
||||||
|
const options: ComboOption[] = (catalogQuery.data?.suppliers ?? [])
|
||||||
|
.filter((s) => !mappedIds.has(s.supplier_id))
|
||||||
|
.map((s) => ({ id: s.supplier_id, label: `${s.name}${s.code ? ` [${s.code}]` : ''}` }));
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!pickSupplierId) {
|
||||||
|
showToast('추가할 공급사를 선택하세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await addSupplier(pickSupplierId, Number(pickType));
|
||||||
|
setPickSupplierId('');
|
||||||
|
setPickLabel('');
|
||||||
|
setQ('');
|
||||||
|
showToast('공급사가 추가되었습니다.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '공급사 추가 실패', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeType = async (supplierItemId: string, v: string) => {
|
||||||
|
try {
|
||||||
|
await changeType(supplierItemId, Number(v));
|
||||||
|
} catch {
|
||||||
|
showToast('공급유형 변경에 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (supplierItemId: string) => {
|
||||||
|
try {
|
||||||
|
await removeSupplier(supplierItemId);
|
||||||
|
showToast('공급사가 삭제되었습니다.', 'info');
|
||||||
|
} catch {
|
||||||
|
showToast('공급사 삭제에 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 border-t border-border space-y-2">
|
||||||
|
<Typography as="label" variant="label">공급사 ({suppliers.length})</Typography>
|
||||||
|
|
||||||
|
{/* 추가 행 — 공급사 + 공급유형 선택 후 추가 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Combobox
|
||||||
|
id="item-supplier-pick"
|
||||||
|
options={options}
|
||||||
|
loading={catalogQuery.isLoading}
|
||||||
|
onQueryChange={setQ}
|
||||||
|
value={pickSupplierId || undefined}
|
||||||
|
selectedLabel={pickLabel}
|
||||||
|
onSelect={(opt) => { setPickSupplierId(opt.id); setPickLabel(opt.label); }}
|
||||||
|
placeholder="공급사로 추가할 협력사 검색..."
|
||||||
|
searchPlaceholder="협력사명·코드로 검색..."
|
||||||
|
emptyText="일치하는 협력사가 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 shrink-0">
|
||||||
|
<Select value={pickType} onValueChange={(v) => setPickType(v ?? String(SupplierType.NONE))}>
|
||||||
|
<SelectTrigger id="item-supplier-pick-type" className="w-full">
|
||||||
|
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={handleAdd} disabled={busy || !pickSupplierId}>
|
||||||
|
<Plus />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 공급사 목록 */}
|
||||||
|
<div className="border border-border rounded divide-y divide-border max-h-48 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">불러오는 중…</Typography>
|
||||||
|
) : suppliers.length === 0 ? (
|
||||||
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">등록된 공급사가 없습니다.</Typography>
|
||||||
|
) : (
|
||||||
|
suppliers.map((m) => (
|
||||||
|
<div key={m.supplier_id} className="flex items-center gap-2 p-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Typography as="span" variant="small" className="font-semibold block truncate">
|
||||||
|
{m.supplier_name ?? '-'}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 shrink-0">
|
||||||
|
<Select
|
||||||
|
value={String(m.supply_type)}
|
||||||
|
onValueChange={(v) => v != null && m.supplier_item_id && handleChangeType(String(m.supplier_item_id), v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => m.supplier_item_id && handleRemove(String(m.supplier_item_id))}
|
||||||
|
title="공급사 삭제"
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,9 @@ 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 } from '@/stores/auth';
|
||||||
|
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
|
||||||
|
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||||
|
import { ItemSuppliersManager } from './ItemSuppliersManager';
|
||||||
import { type Product } from '../types';
|
import { type Product } from '../types';
|
||||||
|
|
||||||
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
||||||
@ -128,7 +131,13 @@ export function ProductFormSheet({
|
|||||||
defaultValues: buildDefaults(mode, product),
|
defaultValues: buildDefaults(mode, product),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
|
const label = useLabels(); // 회사 설정 용어
|
||||||
|
// 배송유형 선택지 — 회사 설정 용어(delivery_type.N)로 라벨만 치환(코드값 불변)
|
||||||
|
const deliveryTypes = DELIVERY_TYPE_OPTIONS.map((o) => ({ ...o, label: label(`delivery_type.${o.value}`) }));
|
||||||
|
// 회사 상품 커스텀필드(정의=companies.settings.item_fields, 값=items.custom)
|
||||||
|
const { settings } = useCompanySettings();
|
||||||
|
const itemFields = settings.item_fields ?? [];
|
||||||
|
const customValues = useCustomFieldValues(itemFields, product?.custom);
|
||||||
|
|
||||||
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
||||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||||
@ -162,6 +171,7 @@ export function ProductFormSheet({
|
|||||||
internet_lowest_price: v.minPrice,
|
internet_lowest_price: v.minPrice,
|
||||||
purchase_price: v.purchasePrice,
|
purchase_price: v.purchasePrice,
|
||||||
selling_price: v.sellingPrice,
|
selling_price: v.sellingPrice,
|
||||||
|
...(itemFields.length > 0 ? { custom: customValues.values } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
@ -235,7 +245,7 @@ export function ProductFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
|
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">분류 카테고리</Typography>
|
<Typography as="label" variant="label">{label('category')}</Typography>
|
||||||
<Input
|
<Input
|
||||||
id="form-product-category"
|
id="form-product-category"
|
||||||
list="form-product-category-options"
|
list="form-product-category-options"
|
||||||
@ -263,7 +273,7 @@ export function ProductFormSheet({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">상품 단가 (₩)</Typography>
|
<Typography as="label" variant="label">{label('item.price')} (₩)</Typography>
|
||||||
<Input
|
<Input
|
||||||
id="form-product-price"
|
id="form-product-price"
|
||||||
type="number"
|
type="number"
|
||||||
@ -359,7 +369,7 @@ export function ProductFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
{/* Lead Time */}
|
{/* Lead Time */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">배송 리드타임 (일)</Typography>
|
<Typography as="label" variant="label">{label('lead_time')} (일)</Typography>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
{...register('leadTime', { valueAsNumber: true })}
|
{...register('leadTime', { valueAsNumber: true })}
|
||||||
@ -465,6 +475,12 @@ export function ProductFormSheet({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||||
|
{mode === 'edit' && product && <ItemSuppliersManager itemId={product.item_id} />}
|
||||||
|
|
||||||
|
{/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */}
|
||||||
|
<CustomFieldInputs fields={itemFields} state={customValues} title="회사 추가 항목" />
|
||||||
|
|
||||||
{/* Drag-and-Drop Image Dropzone */}
|
{/* Drag-and-Drop Image Dropzone */}
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Image as ImageIcon } from 'lucide-react';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTable } from '@/components/ui/data-table';
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
import { TablePagination } from '@/components/ui/table-pagination';
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import { type Product } from '../types';
|
import { type Product } from '../types';
|
||||||
|
|
||||||
type ProductTableProps = {
|
type ProductTableProps = {
|
||||||
@ -27,6 +28,7 @@ export function ProductTable({
|
|||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: ProductTableProps) {
|
}: ProductTableProps) {
|
||||||
|
const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등)
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
data={data}
|
data={data}
|
||||||
@ -73,7 +75,7 @@ export function ProductTable({
|
|||||||
cell: (prod) => prod.code,
|
cell: (prod) => prod.code,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '카테고리',
|
header: label('category'),
|
||||||
align: 'left',
|
align: 'left',
|
||||||
cell: (prod) => (
|
cell: (prod) => (
|
||||||
<Badge variant="outline" className="text-[10.5px] border-border text-foreground font-medium bg-muted py-0.5 px-1.5 rounded-full">
|
<Badge variant="outline" className="text-[10.5px] border-border text-foreground font-medium bg-muted py-0.5 px-1.5 rounded-full">
|
||||||
@ -82,7 +84,21 @@ export function ProductTable({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '상품 단가',
|
header: '공급사',
|
||||||
|
align: 'left',
|
||||||
|
cell: (prod) => {
|
||||||
|
const names = prod.supplier_names ?? [];
|
||||||
|
if (names.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
|
return (
|
||||||
|
<span title={names.join(', ')} className="whitespace-nowrap">
|
||||||
|
{names[0]}
|
||||||
|
{names.length > 1 && <span className="text-muted-foreground"> 외 {names.length - 1}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: label('item.price'),
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cellClassName: 'font-mono font-bold text-foreground',
|
cellClassName: 'font-mono font-bold text-foreground',
|
||||||
cell: (prod) => `₩${(prod.price || 0).toLocaleString()}`,
|
cell: (prod) => `₩${(prod.price || 0).toLocaleString()}`,
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
useListItemSupplyTypes,
|
||||||
|
createSupplierItem,
|
||||||
|
updateSupplyType,
|
||||||
|
deleteSupplierItem,
|
||||||
|
getListItemSupplyTypesQueryKey,
|
||||||
|
} from '@/api/generated/supplier-item/supplier-item';
|
||||||
|
import type { ItemSupplyType } from '@/api/generated/model/itemSupplyType';
|
||||||
|
|
||||||
|
// 상품 취급 공급사(매핑) 서버 데이터 + CRUD — useSupplierItems(협력사측)의 상품측 미러.
|
||||||
|
// 같은 partner.supplier_items 매핑을 상품 상세(ProductFormSheet)에서 편집한다.
|
||||||
|
// itemId 가 없으면(신규 등록 폼) 쿼리는 비활성.
|
||||||
|
export function useItemSuppliers(itemId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const listQuery = useListItemSupplyTypes(itemId ?? '', { query: { enabled: !!itemId } });
|
||||||
|
|
||||||
|
const refresh = () =>
|
||||||
|
itemId
|
||||||
|
? queryClient.invalidateQueries({ queryKey: getListItemSupplyTypesQueryKey(itemId) })
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
const suppliers: ItemSupplyType[] = listQuery.data?.suppliers ?? [];
|
||||||
|
|
||||||
|
const addSupplier = async (supplierId: string, supplyType: number) => {
|
||||||
|
if (!itemId) return;
|
||||||
|
const res = await createSupplierItem({ supplier_id: supplierId, item_id: itemId, supply_type: supplyType });
|
||||||
|
const r = res.result;
|
||||||
|
if (r && r.success === false) {
|
||||||
|
throw new Error(r.desc === 'DB_ALREADY_SAME_KEY' ? '이미 등록된 공급사입니다.' : r.desc || '공급사 추가 실패');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeType = async (supplierItemId: string, supplyType: number) => {
|
||||||
|
await updateSupplyType(supplierItemId, { supply_type: supplyType });
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSupplier = async (supplierItemId: string) => {
|
||||||
|
await deleteSupplierItem(supplierItemId);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { suppliers, isLoading: listQuery.isLoading, addSupplier, changeType, removeSupplier };
|
||||||
|
}
|
||||||
@ -1,11 +1,12 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
|
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||||
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
import { useListCards } from '@/api/generated/card/card';
|
import { useListCards } from '@/api/generated/card/card';
|
||||||
import { mapCardData } from '@/features/cards/types';
|
import { mapCardData } from '@/features/cards/types';
|
||||||
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -125,6 +126,7 @@ export function QuotationCreateModal({
|
|||||||
|
|
||||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||||
|
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||||
const supplyTypeBySupplier = useMemo(() => {
|
const supplyTypeBySupplier = useMemo(() => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number>();
|
||||||
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
||||||
@ -232,6 +234,17 @@ export function QuotationCreateModal({
|
|||||||
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
|
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
|
||||||
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
|
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
|
||||||
};
|
};
|
||||||
|
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
|
||||||
|
const selectAllCards = () => {
|
||||||
|
const rows = cardRows.filter((c) => !c.isWildcard || c.status === 'ACTIVE');
|
||||||
|
setCardDetails((m) => {
|
||||||
|
const next = new Map(m);
|
||||||
|
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]);
|
||||||
|
};
|
||||||
|
const clearAllCards = () => setSelectedCardIds([]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
@ -496,7 +509,7 @@ export function QuotationCreateModal({
|
|||||||
{(value) => {
|
{(value) => {
|
||||||
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
|
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
|
||||||
return qs
|
return qs
|
||||||
? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
||||||
: '';
|
: '';
|
||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
@ -504,7 +517,7 @@ export function QuotationCreateModal({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{quotationSettings.map((qs) => (
|
{quotationSettings.map((qs) => (
|
||||||
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
|
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
|
||||||
[목표 마진: {qs.target_margin}] 카드 {qs.card_use_count}
|
[{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -559,7 +572,19 @@ export function QuotationCreateModal({
|
|||||||
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
|
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
|
||||||
{step === 4 && oneToOne && (
|
{step === 4 && oneToOne && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택 (선택)</Typography>
|
<Typography as="span" variant="label" className="block">협상카드 및 와일드카드 선택 (선택)</Typography>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1" onClick={selectAllCards}>
|
||||||
|
<CheckCheck size={13} />
|
||||||
|
현재 목록 전체선택
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1 text-muted-foreground" onClick={clearAllCards} disabled={selectedCardIds.length === 0}>
|
||||||
|
<X size={13} />
|
||||||
|
전체해제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
|
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
|
||||||
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user