Merge branch 'feature/negodata'

This commit is contained in:
Mina Choi 2026-06-23 08:31:13 +09:00
commit 5d9eedf2f6
80 changed files with 1989 additions and 1791 deletions

View File

@ -37,3 +37,9 @@ access_key = "<JWT_ACCESS_SECRET>"
refresh_key = "<JWT_REFRESH_SECRET>"
access_expire_min = 30
refresh_expire_day = 7
# 협상 agent(포트 9500) 접속. use_mock=true 면 agent 미연동 — 내장 mock 응답 사용(통합 테스트/로컬 기본).
[AgentConfig]
base_url = "http://127.0.0.1:9500"
timeout_sec = 10.0
use_mock = true

View File

@ -35,6 +35,10 @@ services:
environment:
APP_ENV: local
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override)
RELOAD: "1" # uvicorn --reload 활성 → 소스 저장 시 자동 재기동(재빌드 불필요)
SCHEDULER_ENABLED: "1" # 마감 크론 활성(단일 워커라 중복 없음). 운영 다중 워커면 1개 프로세스에서만 1
volumes:
- ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨
ports:
- "9400:9400"
extra_hosts:

View File

@ -3,6 +3,17 @@ from enum import Enum, auto
from fastapi import HTTPException
class CodeEnum(Enum):
"""OpenAPI 스키마에 x-enum-varnames(멤버 이름)을 실어 orval 이 이름 있는 enum 을 생성하게 하는 베이스."""
@classmethod
def __get_pydantic_json_schema__(cls, core_schema, handler):
json_schema = handler(core_schema)
json_schema = handler.resolve_ref_schema(json_schema)
json_schema["x-enum-varnames"] = [m.name for m in cls]
return json_schema
class ErrorType(Enum):
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
HTTP status 겹치지 않도록 구간을 분리해서 관리한다.
@ -84,35 +95,35 @@ class DBWRType(Enum):
# 도메인 코드값
class UserStatus(Enum):
class UserStatus(CodeEnum):
"""users.status 코드값."""
ACTIVE = 1
INACTIVE = 2
class UserRole(Enum):
class UserRole(CodeEnum):
"""users.role 코드값."""
USER = 1
MANAGER = 2
class CompanyStatus(Enum):
class CompanyStatus(CodeEnum):
"""companies.status 코드값."""
ACTIVE = 1
INACTIVE = 2
class QuotationType(Enum):
class QuotationType(CodeEnum):
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
RENEGO = 1
REQUOTE = 2
class QuotationStatus(Enum):
class QuotationStatus(CodeEnum):
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
CREATED = 1
@ -121,7 +132,7 @@ class QuotationStatus(Enum):
ON_HOLD = 4
class SessionStatus(Enum):
class SessionStatus(CodeEnum):
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
CREATED = 1
@ -131,14 +142,14 @@ class SessionStatus(Enum):
REJECTED = 5
class ChatSender(Enum):
class ChatSender(CodeEnum):
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
BOT = 1
USER = 2
class DeliveryType(Enum):
class DeliveryType(CodeEnum):
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
PARTNER = 1 # 협력사배송
@ -146,50 +157,15 @@ class DeliveryType(Enum):
PICKUP = 3 # 픽업배송
class CardStatus(Enum):
class CardStatus(CodeEnum):
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
ACTIVE = 1
INACTIVE = 2
# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드).
ENUM_LABELS = {
UserStatus.ACTIVE: "활성",
UserStatus.INACTIVE: "비활성",
UserRole.USER: "일반",
UserRole.MANAGER: "관리자",
CompanyStatus.ACTIVE: "활성",
CompanyStatus.INACTIVE: "비활성",
QuotationType.RENEGO: "재협상",
QuotationType.REQUOTE: "재견적",
QuotationStatus.CREATED: "견적생성",
QuotationStatus.ACTIVE: "견적진행중",
QuotationStatus.CLOSED: "견적마감",
QuotationStatus.ON_HOLD: "협상보류",
SessionStatus.CREATED: "협상생성",
SessionStatus.IN_PROGRESS: "협상중",
SessionStatus.DONE: "협상완료",
SessionStatus.NOT_PARTICIPATED: "미참여",
SessionStatus.REJECTED: "협상거부",
ChatSender.BOT: "",
ChatSender.USER: "협력사",
DeliveryType.PARTNER: "협력사배송",
DeliveryType.COURIER: "지정택배배송",
DeliveryType.PICKUP: "픽업배송",
CardStatus.ACTIVE: "적용",
CardStatus.INACTIVE: "대기",
}
class CardType(CodeEnum):
"""negotiation.chats.card_type / quotation_cards.type 코드값. 1=nego_card, 2=wild_card."""
# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다.
DOMAIN_ENUMS = {
"user_status": UserStatus,
"user_role": UserRole,
"company_status": CompanyStatus,
"quotation_type": QuotationType,
"quotation_status": QuotationStatus,
"session_status": SessionStatus,
"chat_sender": ChatSender,
"delivery_type": DeliveryType,
"card_status": CardStatus,
}
NEGO = 1
WILD = 2

View File

@ -2,15 +2,15 @@ from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy import select, func, and_, update
from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, quotation_settings,
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
version_nego_cards, version_wild_cards,
)
from common.enums import ErrorType
from common.enums import ErrorType, QuotationStatus, QuotationType, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -19,7 +19,7 @@ from common.utils.gtime import GTime
class IQuotationCRUD(ABC):
@abstractmethod
async def search(
self, cdb: AsyncSession, status, type_, start_from, start_to, skip, limit
self, cdb: AsyncSession, search, status, type_, start_from, start_to, skip, limit
) -> Tuple[ErrorType, list, int]:
pass
@ -59,6 +59,10 @@ class IQuotationCRUD(ABC):
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
pass
@abstractmethod
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
pass
@abstractmethod
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
pass
@ -83,11 +87,33 @@ class IQuotationCRUD(ABC):
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
pass
# ----- 스케줄러(크론) 전용 -----
@abstractmethod
async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def list_requote_done(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def list_done_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def bulk_update_quotation_status(self, cdb: AsyncSession, qt_ids, status: int) -> ErrorType:
pass
@abstractmethod
async def bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType:
pass
class QuotationCRUD(IQuotationCRUD):
async def search(
self,
cdb: AsyncSession,
search: Optional[str],
status: Optional[str],
type_: Optional[str],
start_from: Optional[datetime],
@ -97,10 +123,12 @@ class QuotationCRUD(IQuotationCRUD):
) -> Tuple[ErrorType, list, int]:
try:
conditions = [quotations.deleted == False] # noqa: E712
if search:
conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%")))
if status:
conditions.append(quotations.status == status)
conditions.append(quotations.status == int(status)) # status/type 는 SMALLINT 코드 — 문자열 쿼리값을 정수로
if type_:
conditions.append(quotations.type == type_)
conditions.append(quotations.type == int(type_))
if start_from:
conditions.append(quotations.start_time >= start_from)
if start_to:
@ -308,6 +336,23 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
# 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다.
try:
query = (
update(sessions)
.where(
sessions.quotation_id == qt_id,
sessions.status.in_(from_statuses),
sessions.deleted == False, # noqa: E712
)
.values(status=to_status, 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 soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
try:
query = update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=GTime.UTC())
@ -316,6 +361,106 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
# ----- 스케줄러(크론) 전용 -----
async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]:
"""[잡①] 마감시각이 지났는데 아직 안 닫힌 견적 qt_id 목록.
조건: end_time < now AND status != 견적마감 AND not deleted."""
try:
query = select(quotations.qt_id).where(
quotations.end_time < now,
quotations.status != QuotationStatus.CLOSED.value,
quotations.deleted == False, # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def list_requote_done(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
"""[잡②] 재견적(REQUOTE) 중 협상완료(DONE) 세션이 1건 이상이고 아직 안 닫힌 견적 qt_id 목록.
재견적은 세션이 독립적이라 하나라도 완료되면 나머지를 기다리지 않고 마감 대상."""
try:
done_exists = (
select(sessions.session_id)
.where(
sessions.quotation_id == quotations.qt_id,
sessions.status == SessionStatus.DONE.value,
sessions.deleted == False, # noqa: E712
)
.exists()
)
query = select(quotations.qt_id).where(
quotations.type == QuotationType.REQUOTE.value,
quotations.status != QuotationStatus.CLOSED.value,
quotations.deleted == False, # noqa: E712
done_exists,
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def list_done_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
"""[잡②] 견적의 협상완료(DONE) 세션 → (supplier_id, bid_price, supplier_name) 목록. 낙찰자 판정 입력."""
try:
query = (
select(sessions.supplier_id, sessions.bid_price, suppliers.name)
.join(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(
sessions.quotation_id == qt_id,
sessions.status == SessionStatus.DONE.value,
sessions.deleted == False, # noqa: E712
suppliers.deleted == False, # noqa: E712
)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def bulk_update_quotation_status(self, cdb: AsyncSession, qt_ids, status: int) -> ErrorType:
"""[잡①] 여러 견적의 status 를 한 번에 전이."""
try:
if not qt_ids:
return ErrorType.SUCCESS
query = (
update(quotations)
.where(quotations.qt_id.in_(qt_ids))
.values(status=status, 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 bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType:
"""[잡①] 여러 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외)."""
try:
if not qt_ids:
return ErrorType.SUCCESS
query = (
update(sessions)
.where(
sessions.quotation_id.in_(qt_ids),
sessions.status.in_(from_statuses),
sessions.deleted == False, # noqa: E712
)
.values(status=to_status, 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_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
try:

View File

@ -10,3 +10,4 @@ pydantic>=2.0
python-multipart
openpyxl
httpx
apscheduler>=3.10

View File

@ -9,22 +9,24 @@ from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from common.utils.gtime import GTime
from config.server_configs import web_server_config
from scheduler import shutdown_scheduler, start_scheduler
import router.v1.auth.account
import router.v1.item.item
import router.v1.supplier.supplier
import router.v1.card.card
import router.v1.quotation.quotation
import router.v1.quotation_setting.quotation_setting
import router.v1.enums.enums
API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
# startup: 마감 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만)
start_scheduler()
yield
# shutdown: DB 엔진 커넥션 풀 정리
# shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리
shutdown_scheduler()
await DB_SESSION_MNG.dispose_all()
@ -64,4 +66,3 @@ app.include_router(router.v1.supplier.supplier.router)
app.include_router(router.v1.card.card.router)
app.include_router(router.v1.quotation.quotation.router)
app.include_router(router.v1.quotation_setting.quotation_setting.router)
app.include_router(router.v1.enums.enums.router)

View File

@ -52,6 +52,5 @@ class Res_Me(Res_WebPacketProtocol):
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
role: int = UserRole.USER.value
role_label: str = ""
role: UserRole = UserRole.USER
company: Optional[CompanyData] = Field(default=None)

View File

@ -21,9 +21,10 @@ async def list_cards(
service: CardService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"),
is_wildcard: bool | None = Query(None, description="탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, pg))
return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, is_wildcard, pg))
@router.post(path="/create", response_model=Res_Card, summary="협상카드 등록")

View File

@ -44,7 +44,7 @@ class CardData(WebPacketProtocol):
number: Optional[str] = None
script: Optional[str] = None
edit_script: Optional[Any] = None
status: int = CardStatus.ACTIVE.value
status: CardStatus = CardStatus.ACTIVE
condition: Optional[str] = None
memo: Optional[str] = None
created_at: Optional[datetime] = None
@ -57,6 +57,8 @@ class Res_Card(Res_WebPacketProtocol):
class Res_CardList(Res_PageProtocol):
cards: list[CardData] = []
total_nego: int = 0 # 협상카드 탭 카운트(검색 필터 반영)
total_wild: int = 0 # 와일드카드 탭 카운트(검색 필터 반영)
class Res_DeleteCard(Res_WebPacketProtocol):

View File

@ -1,21 +0,0 @@
from fastapi import APIRouter
from common.enums import DOMAIN_ENUMS, ENUM_LABELS
from router.v1.validator.dependencies import RemoveNoneResponse
from .protocol import EnumOption, Res_Enums
# 도메인 코드 enum 메타데이터(공용). 프론트가 페이지 진입 시 드롭다운을 이걸로 채운다.
router = APIRouter(prefix="/v1", tags=["Enums"], responses={404: {"description": "Not found"}})
@router.get(path="/enums", response_model=Res_Enums, summary="도메인 코드 enum 전체")
async def list_enums():
res = Res_Enums()
res.enums = {
key: [
EnumOption(value=member.value, name=member.name, label=ENUM_LABELS.get(member, member.name))
for member in enum_cls
]
for key, enum_cls in DOMAIN_ENUMS.items()
}
return RemoveNoneResponse(res)

View File

@ -1,15 +0,0 @@
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class EnumsProtocol(WebPacketProtocol):
pass
class EnumOption(WebPacketProtocol):
value: int
name: str
label: str
class Res_Enums(Res_WebPacketProtocol):
enums: dict[str, list[EnumOption]] = {}

View File

@ -4,6 +4,7 @@ from typing import Optional
from pydantic import ConfigDict
from common.enums import DeliveryType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -71,7 +72,7 @@ class ItemData(WebPacketProtocol):
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None
delivery_type: Optional[int] = None
delivery_type: Optional[DeliveryType] = None
vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None
created_at: Optional[datetime] = None

View File

@ -4,6 +4,7 @@ from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -39,9 +40,9 @@ class QuotationData(WebPacketProtocol):
version_id: uuid.UUID
name: str
number: str
type: int
type: QuotationType
round: int = 1
status: int
status: QuotationStatus
start_time: datetime
end_time: datetime
manager_name: Optional[str] = None
@ -82,15 +83,15 @@ class SessionData(WebPacketProtocol):
item_id: uuid.UUID
qt_number: str
qt_round: int
qt_type: int
qt_type: QuotationType
target_price: int
status: int
status: SessionStatus
bid_price: Optional[int] = None
bid_at: Optional[datetime] = None
end_time: datetime
reject_reason: Optional[str] = None
reject_price: Optional[int] = None
reject_delivery_type: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
@ -124,11 +125,13 @@ class ChatMessageData(WebPacketProtocol):
session_id: uuid.UUID
card_id: Optional[uuid.UUID] = None
index: int
sender: int
sender: ChatSender
target_price: int
card_used_yn: Optional[bool] = None
indicator_value: Optional[float] = None
card_type: Optional[int] = None
card_type: Optional[CardType] = None
script: Optional[str] = None
step: Optional[str] = None
class Res_SessionChat(Res_WebPacketProtocol):
@ -150,7 +153,7 @@ class QuotationCardData(WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
nego_card_id: Optional[uuid.UUID] = None
wild_card_id: Optional[uuid.UUID] = None
type: Optional[int] = None
type: Optional[CardType] = None
number: Optional[str] = None
name: Optional[str] = None
script: Optional[str] = None # 협상 멘트(평문)

View File

@ -31,13 +31,14 @@ router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {
async def list_quotations(
service: QuotationService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="견적명/견적번호 검색"),
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, pg))
return RemoveNoneResponse(await service.list_quotations(search, status, type, start_from, start_to, pg))
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")

View File

@ -0,0 +1,74 @@
"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당.
router/(HTTP 진입점) 동급의 '시간 진입점' 계층. APScheduler 수명주기와 등록(타이밍) 책임지고,
실제로 하는 (what) scheduler/jobs.py 있다.
- 다중 워커(운영)에서 잡이 워커마다 중복 실행되면 되므로 SCHEDULER_ENABLED=1 프로세스에서만 등록한다.
(개발은 RELOAD=1 단일 워커라 docker-compose 에서 SCHEDULER_ENABLED=1 켠다.)
- apscheduler import start_scheduler() 안에서 한다 미설치(이미지 미재빌드) 상태라도 API 부팅된다.
close_expired_quotations : 매일 UTC 00:10(KST 09:10) 마감시각 지난 견적 마감
complete_requote_quotations: 1시간마다 재견적 DONE 세션 있으면 즉시 마감
"""
import os
from common.logger import LOG
from scheduler import jobs
__all__ = ["start_scheduler", "shutdown_scheduler"]
_scheduler = None # AsyncIOScheduler | None
def _is_enabled() -> bool:
return os.environ.get("SCHEDULER_ENABLED", "0") == "1"
def start_scheduler():
"""lifespan startup 에서 호출. SCHEDULER_ENABLED=1 일 때만 스케줄러를 띄운다."""
global _scheduler
if not _is_enabled():
LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)")
return
if _scheduler is not None:
return
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
except ImportError:
# 의존성 미설치(이미지 미재빌드) → API 는 살리고 스케줄러만 끈다.
LOG.e_no_callstack("[scheduler] apscheduler 미설치 → 스케줄러 비활성. requirements 재설치(이미지 재빌드) 필요")
return
_scheduler = AsyncIOScheduler(timezone="UTC")
# 잡 ① 마감시간 처리: 매일 UTC 00:10
_scheduler.add_job(
jobs.close_expired_quotations,
CronTrigger(hour=0, minute=10),
id="close_expired_quotations",
coalesce=True, # 밀린 실행이 여러 번 쌓여도 1번만
misfire_grace_time=3600, # 정시보다 늦게 깨어나도 1시간 내면 실행
max_instances=1,
)
# 잡 ② 재견적 협상완료 처리: 1시간마다
_scheduler.add_job(
jobs.complete_requote_quotations,
IntervalTrigger(hours=1),
id="complete_requote_quotations",
coalesce=True,
misfire_grace_time=600,
max_instances=1,
)
_scheduler.start()
LOG.i("[scheduler] started (close_expired=daily 00:10 UTC, complete_requote=hourly)")
def shutdown_scheduler():
"""lifespan shutdown 에서 호출."""
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
LOG.i("[scheduler] stopped")

View File

@ -0,0 +1,124 @@
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
from crud.quotation_crud import QuotationCRUD
async def close_expired_quotations() -> int:
"""[잡①] 마감일이 지난 견적을 자동으로 견적마감 처리한다. 하루 한 번 실행.
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
처리: 견적들을 견적마감 상태로 바꾸고, 아직 시작 전인 세션은 미참여로 정리한다.
반환: 마감 처리한 견적 ."""
crud = QuotationCRUD()
now = GTime.UTC()
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: crud.list_due_for_close(s, now),
)
if err_type != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] close_expired 대상 조회 실패: {err_type.name}")
return 0
if not qt_ids:
return 0
err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: crud.bulk_update_quotation_status(s, qt_ids, QuotationStatus.CLOSED.value),
lambda s: crud.bulk_update_sessions_status(
s, qt_ids, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
if err_type != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] close_expired 마감 실패: {err_type.name}")
return 0
LOG.i(f"[scheduler] close_expired: {len(qt_ids)}건 견적마감")
return len(qt_ids)
async def complete_requote_quotations() -> int:
"""[잡②] 재견적은 협상완료된 세션이 생기면 나머지를 기다리지 않고 바로 마감한다. 한 시간마다 실행.
대상: 아직 마감되지 않은 재견적 견적 , 협상완료된 세션이 있는 .
낙찰: 협상완료된 세션 입찰가가 가장 낮은 공급사를 낙찰자로 정한다. 같은 최저가가 이상이면(동가) 낙찰자를 비우고 동가 정보만 남긴다.
(현재 재견적은 견적당 세션이 하나라 실제로는 단독 낙찰만 일어나지만, 모델상 1:N이라 일반 규칙을 그대로 둔다.)
처리: 낙찰 정보를 기록하고 견적을 견적마감 상태로 바꾸며, 아직 시작 전인 세션은 미참여로 정리한다.
반환: 마감 처리한 견적 ."""
crud = QuotationCRUD()
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: crud.list_requote_done(s),
)
if err_type != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] complete_requote 대상 조회 실패: {err_type.name}")
return 0
if not qt_ids:
return 0
closed = 0
for qt_id in qt_ids:
e2, done_rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s, q=qt_id: crud.list_done_sessions(s, q),
)
if e2 != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] complete_requote DONE세션 조회 실패 qt_id={qt_id}: {e2.name}")
continue
# 현재 재견적은 세션이 하나라 사실상 단독 낙찰만 타지만, 모델상 1:N이라 일반 규칙(_pick_winner)을 그대로 쓴다.
winner, equal = _pick_winner(done_rows)
# 단독 낙찰과 동가는 상호배타(KTC 정본). 플래그를 명시적으로 박는다.
data = {
"status": QuotationStatus.CLOSED.value,
"preferred_sp_yn": winner is not None,
"equal_bid_yn": equal is not None,
}
if winner is not None:
data["preferred_sp_id"] = winner["supplier_id"]
data["preferred_sp_name"] = (winner["name"] or "")[:20]
if equal is not None:
data["equal_bid_data"] = equal
e3 = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s, d=data, q=qt_id: crud.update_quotation(s, q, d),
lambda s, q=qt_id: crud.update_sessions_status(
s, q, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
if e3 == ErrorType.SUCCESS:
closed += 1
else:
LOG.e_no_callstack(f"[scheduler] complete_requote 마감 실패 qt_id={qt_id}: {e3.name}")
if closed:
LOG.i(f"[scheduler] complete_requote: {closed}건 견적마감")
return closed
def _pick_winner(done_rows):
"""협상완료된 세션들 중에서 낙찰자를 정한다(KTC 정본 규칙). complete_requote_quotations 전용 헬퍼.
입찰가가 매겨진 세션들 가운데 가장 낮은 가격을 부른 공급사를 낙찰자로 본다.
- 최저가를 부른 곳이 곳뿐이면: 공급사를 낙찰자로 정하고, 동가는 없다.
- 최저가가 이상으로 같으면(동가): 낙찰자는 비우고 동가 정보(최저가와 공급사들) 남긴다.
- 입찰가가 매겨진 세션이 하나도 없으면: 낙찰자도 동가 정보도 없다.
낙찰자와 동가 정보를 쌍으로 돌려주며, 둘은 동시에 채워지지 않는다(단독 낙찰 또는 동가, 하나)."""
cands = [(sid, int(bp), name) for sid, bp, name in done_rows if bp is not None]
if not cands:
return None, None
min_price = min(c[1] for c in cands)
tied = [c for c in cands if c[1] == min_price]
if len(tied) > 1: # 동가입찰: 최저가가 여럿 → 낙찰 미지정, 동가만 기록
equal = {
"price": min_price,
"suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied],
}
return None, equal
return {"supplier_id": tied[0][0], "name": tied[0][2]}, None

View File

@ -4,7 +4,7 @@ from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, UserStatus, UserRole, ENUM_LABELS
from common.enums import DBWRType, ErrorType, UserStatus, UserRole
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD
@ -156,7 +156,6 @@ class AuthService:
res.email = user.email
res.contact_number = user.contact_number
res.role = user.role
res.role_label = ENUM_LABELS.get(UserRole(user.role), str(user.role))
res.company = company
return res

View File

@ -79,18 +79,23 @@ class CardService:
return ErrorType.CARD_NOT_FOUND, None, None, None, False
# ---- 목록 ----------------------------------------------------------------
async def list_cards(self, user_id: str, search, pg: PageParams) -> Res_CardList:
async def list_cards(self, user_id: str, search, is_wildcard, pg: PageParams) -> Res_CardList:
"""is_wildcard: None=전체(두 테이블 머지) / False=협상카드만 / True=와일드카드만.
탭이 무엇이든 양쪽 카운트(total_nego/total_wild) 항상 채운다(검색 필터 반영).
선택 탭은 limit=0 으로 카운트만 받아 행은 가져오지 않는다."""
res = Res_CardList(page=pg.page, size=pg.size)
if not user_id:
return res
user_uuid = uuid.UUID(user_id)
# 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분).
fetch = pg.skip + pg.size
nego_limit = 0 if is_wildcard is True else fetch
wild_limit = 0 if is_wildcard is False else fetch
err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(),
DBWRType.DB_READ.value,
lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, fetch),
lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, nego_limit),
)
if err_n != ErrorType.SUCCESS:
res.result.SetResult(err_n)
@ -99,7 +104,7 @@ class CardService:
err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda(
wild_cards.DBType(),
DBWRType.DB_READ.value,
lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, fetch),
lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, wild_limit),
)
if err_w != ErrorType.SUCCESS:
res.result.SetResult(err_w)
@ -108,7 +113,15 @@ class CardService:
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
merged.sort(key=lambda c: c.created_at or "", reverse=True)
res.cards = merged[pg.skip : pg.skip + pg.size]
res.total = total_n + total_w
res.total_nego = total_n
res.total_wild = total_w
# 선택된 탭 기준 페이지네이션 총건수(전체=합산).
if is_wildcard is True:
res.total = total_w
elif is_wildcard is False:
res.total = total_n
else:
res.total = total_n + total_w
return res
# ---- 단건 조회 -----------------------------------------------------------

View File

@ -82,13 +82,13 @@ class QuotationService:
return ErrorType.QUOTATION_NOT_FOUND, None
return ErrorType.SUCCESS, quotation
async def list_quotations(self, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
res = Res_QuotationList(page=pg.page, size=pg.size)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.search(s, status, type_, start_from, start_to, pg.skip, pg.size),
lambda s: self.quotation_crud.search(s, search, status, type_, start_from, start_to, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -274,10 +274,16 @@ class QuotationService:
res.result.SetResult(err_type)
return res
# 상태를 '견적마감'으로 변경(실제 DB 업데이트)
# 견적 '견적마감'(CLOSED) + 딸린 세션 정리를 한 트랜잭션으로.
# 세션은 아직 시작 전(협상생성)인 것만 미참여로 떨군다. 협상중/완료/거부/미참여는 그대로 둔다.
err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value})],
[
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value}),
lambda s: self.quotation_crud.update_sessions_status(
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -387,6 +393,7 @@ class QuotationService:
return res
# chats.seq → ChatMessageData.index 로 매핑. indicator_value(Decimal) → float.
# 말풍선 텍스트는 chats.meta.script 에 영속화돼 있어 그대로 꺼낸다(프론트 하드코딩 X).
res.messages = [
ChatMessageData(
chat_id=r.chat_id,
@ -398,6 +405,8 @@ class QuotationService:
card_used_yn=r.card_used_yn,
indicator_value=float(r.indicator_value) if r.indicator_value is not None else None,
card_type=r.card_type,
script=(r.meta or {}).get("script"),
step=(r.meta or {}).get("step"),
)
for r in rows
]

View File

@ -6,6 +6,8 @@
# 또는 uvicorn 직접 실행:
# uvicorn router.router:app --reload --host=0.0.0.0 --port=9400
import os
import uvicorn
from common.logger import LOG
@ -21,21 +23,20 @@ if __name__ == "__main__":
LOG.i(f"Server Port : {web_server_config.port}")
LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}")
if web_server_config.is_ssl:
uvicorn.run(
"router.router:app",
host="0.0.0.0",
port=web_server_config.port,
access_log=False,
workers=web_server_config.process_count,
ssl_keyfile="./SSL/key.pem",
ssl_certfile="./SSL/cert.pem",
)
# RELOAD=1 (개발 컨테이너) → 소스 변경 시 자동 재기동. reload 와 workers(다중) 는 함께 못 쓰므로 분기.
reload = os.environ.get("RELOAD") == "1"
run_kwargs = dict(
host="0.0.0.0",
port=web_server_config.port,
access_log=False,
)
if reload:
run_kwargs["reload"] = True
else:
uvicorn.run(
"router.router:app",
host="0.0.0.0",
port=web_server_config.port,
access_log=False,
workers=web_server_config.process_count,
)
run_kwargs["workers"] = web_server_config.process_count
if web_server_config.is_ssl:
run_kwargs["ssl_keyfile"] = "./SSL/key.pem"
run_kwargs["ssl_certfile"] = "./SSL/cert.pem"
uvicorn.run("router.router:app", **run_kwargs)

View File

@ -1,124 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import {
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
ResEnums
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary enum
*/
export const listEnums = (
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResEnums>(
{url: `/v1/enums`, method: 'GET', signal
},
options);
}
export const getListEnumsQueryKey = () => {
return [
`/v1/enums`
] as const;
}
export const getListEnumsQueryOptions = <TData = Awaited<ReturnType<typeof listEnums>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListEnumsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listEnums>>> = ({ signal }) => listEnums(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListEnumsQueryResult = NonNullable<Awaited<ReturnType<typeof listEnums>>>
export type ListEnumsQueryError = void
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listEnums>>,
TError,
Awaited<ReturnType<typeof listEnums>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listEnums>>,
TError,
Awaited<ReturnType<typeof listEnums>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary enum
*/
export function useListEnums<TData = Awaited<ReturnType<typeof listEnums>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listEnums>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListEnumsQueryOptions(options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -9,6 +9,7 @@ import type { CardDataName } from './cardDataName';
import type { CardDataNumber } from './cardDataNumber';
import type { CardDataScript } from './cardDataScript';
import type { CardDataEditScript } from './cardDataEditScript';
import type { CardStatus } from './cardStatus';
import type { CardDataCondition } from './cardDataCondition';
import type { CardDataMemo } from './cardDataMemo';
import type { CardDataCreatedAt } from './cardDataCreatedAt';
@ -22,7 +23,7 @@ export interface CardData {
number?: CardDataNumber;
script?: CardDataScript;
edit_script?: CardDataEditScript;
status?: number;
status?: CardStatus;
condition?: CardDataCondition;
memo?: CardDataMemo;
created_at?: CardDataCreatedAt;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* nego_cards.status . ( ). ACTIVE.
*/
export type CardStatus = typeof CardStatus[keyof typeof CardStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const CardStatus = {
ACTIVE: 1,
INACTIVE: 2,
} as const;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* negotiation.chats.card_type / quotation_cards.type . 1=nego_card, 2=wild_card.
*/
export type CardType = typeof CardType[keyof typeof CardType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const CardType = {
NEGO: 1,
WILD: 2,
} as const;

View File

@ -5,18 +5,23 @@
* OpenAPI spec version: 0.1.0
*/
import type { ChatMessageDataCardId } from './chatMessageDataCardId';
import type { ChatSender } from './chatSender';
import type { ChatMessageDataCardUsedYn } from './chatMessageDataCardUsedYn';
import type { ChatMessageDataIndicatorValue } from './chatMessageDataIndicatorValue';
import type { ChatMessageDataCardType } from './chatMessageDataCardType';
import type { ChatMessageDataScript } from './chatMessageDataScript';
import type { ChatMessageDataStep } from './chatMessageDataStep';
export interface ChatMessageData {
chat_id: string;
session_id: string;
card_id?: ChatMessageDataCardId;
index: number;
sender: number;
sender: ChatSender;
target_price: number;
card_used_yn?: ChatMessageDataCardUsedYn;
indicator_value?: ChatMessageDataIndicatorValue;
card_type?: ChatMessageDataCardType;
script?: ChatMessageDataScript;
step?: ChatMessageDataStep;
}

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardType } from './cardType';
export type ChatMessageDataCardType = number | null;
export type ChatMessageDataCardType = CardType | null;

View File

@ -5,8 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export interface EnumOption {
value: number;
name: string;
label: string;
}
export type ChatMessageDataScript = string | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ResEnumsMsg = string | null;
export type ChatMessageDataStep = string | null;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* negotiation.chats.sender . .
*/
export type ChatSender = typeof ChatSender[keyof typeof ChatSender];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ChatSender = {
BOT: 1,
USER: 2,
} as const;

View File

@ -0,0 +1,19 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* items.delivery_type . .
*/
export type DeliveryType = typeof DeliveryType[keyof typeof DeliveryType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const DeliveryType = {
PARTNER: 1,
COURIER: 2,
PICKUP: 3,
} as const;

View File

@ -17,13 +17,18 @@ export * from './cardDataNumber';
export * from './cardDataScript';
export * from './cardDataUpdatedAt';
export * from './cardDataUserId';
export * from './cardStatus';
export * from './cardType';
export * from './chatMessageData';
export * from './chatMessageDataCardId';
export * from './chatMessageDataCardType';
export * from './chatMessageDataCardUsedYn';
export * from './chatMessageDataIndicatorValue';
export * from './chatMessageDataScript';
export * from './chatMessageDataStep';
export * from './chatSender';
export * from './companyData';
export * from './enumOption';
export * from './deliveryType';
export * from './errorInfo';
export * from './errorInfoCode';
export * from './errorInfoDesc';
@ -80,6 +85,8 @@ export * from './quotationSettingData';
export * from './quotationSettingDataCreatedAt';
export * from './quotationSettingDataUpdatedAt';
export * from './quotationSettingDataUserId';
export * from './quotationStatus';
export * from './quotationType';
export * from './reqCheckCodes';
export * from './reqCreateAccount';
export * from './reqCreateCard';
@ -179,9 +186,6 @@ export * from './resDeleteQuotationSetting';
export * from './resDeleteQuotationSettingMsg';
export * from './resDeleteSupplier';
export * from './resDeleteSupplierMsg';
export * from './resEnums';
export * from './resEnumsEnums';
export * from './resEnumsMsg';
export * from './resItem';
export * from './resItemCategories';
export * from './resItemCategoriesMsg';
@ -248,6 +252,7 @@ export * from './sessionDataBidPrice';
export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason';
export * from './sessionStatus';
export * from './supplierData';
export * from './supplierDataCode';
export * from './supplierDataCreatedAt';
@ -256,6 +261,7 @@ export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName';
export * from './supplierDataPriority';
export * from './supplierDataUpdatedAt';
export * from './userRole';
export * from './validationError';
export * from './validationErrorCtx';
export * from './validationErrorLocItem';

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DeliveryType } from './deliveryType';
export type ItemDataDeliveryType = number | null;
export type ItemDataDeliveryType = DeliveryType | null;

View File

@ -10,6 +10,10 @@ export type ListCardsParams = {
* //
*/
search?: string | null;
/**
* 필터: 미지정= / false= / true=
*/
is_wildcard?: boolean | null;
/**
* @minimum 1
*/

View File

@ -6,6 +6,10 @@
*/
export type ListQuotationsParams = {
/**
* /
*/
search?: string | null;
/**
* ( )
*/

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardType } from './cardType';
export type QuotationCardDataType = number | null;
export type QuotationCardDataType = CardType | null;

View File

@ -4,6 +4,8 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { QuotationType } from './quotationType';
import type { QuotationStatus } from './quotationStatus';
import type { QuotationDataManagerName } from './quotationDataManagerName';
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
@ -25,9 +27,9 @@ export interface QuotationData {
version_id: string;
name: string;
number: string;
type: number;
type: QuotationType;
round?: number;
status: number;
status: QuotationStatus;
start_time: string;
end_time: string;
manager_name?: QuotationDataManagerName;

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* quotations.status (SMALLINT). .
*/
export type QuotationStatus = typeof QuotationStatus[keyof typeof QuotationStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const QuotationStatus = {
CREATED: 1,
ACTIVE: 2,
CLOSED: 3,
ON_HOLD: 4,
} as const;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* quotations.type . 1=renego( 1:1), 2=requote( 1:N).
*/
export type QuotationType = typeof QuotationType[keyof typeof QuotationType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const QuotationType = {
RENEGO: 1,
REQUOTE: 2,
} as const;

View File

@ -15,4 +15,6 @@ export interface ResCardList {
page?: number;
size?: number;
cards?: CardData[];
total_nego?: number;
total_wild?: number;
}

View File

@ -1,15 +0,0 @@
/**
* 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 { ResEnumsMsg } from './resEnumsMsg';
import type { ResEnumsEnums } from './resEnumsEnums';
export interface ResEnums {
result?: ErrorInfo;
msg?: ResEnumsMsg;
enums?: ResEnumsEnums;
}

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { EnumOption } from './enumOption';
export type ResEnumsEnums = {[key: string]: EnumOption[]};

View File

@ -9,6 +9,7 @@ import type { ResMeMsg } from './resMeMsg';
import type { ResMeName } from './resMeName';
import type { ResMeEmail } from './resMeEmail';
import type { ResMeContactNumber } from './resMeContactNumber';
import type { UserRole } from './userRole';
import type { ResMeCompany } from './resMeCompany';
export interface ResMe {
@ -19,7 +20,6 @@ export interface ResMe {
name?: ResMeName;
email?: ResMeEmail;
contact_number?: ResMeContactNumber;
role?: number;
role_label?: string;
role?: UserRole;
company?: ResMeCompany;
}

View File

@ -4,6 +4,8 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { QuotationType } from './quotationType';
import type { SessionStatus } from './sessionStatus';
import type { SessionDataBidPrice } from './sessionDataBidPrice';
import type { SessionDataBidAt } from './sessionDataBidAt';
import type { SessionDataRejectReason } from './sessionDataRejectReason';
@ -17,9 +19,9 @@ export interface SessionData {
item_id: string;
qt_number: string;
qt_round: number;
qt_type: number;
qt_type: QuotationType;
target_price: number;
status: number;
status: SessionStatus;
bid_price?: SessionDataBidPrice;
bid_at?: SessionDataBidAt;
end_time: string;

View File

@ -4,5 +4,6 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DeliveryType } from './deliveryType';
export type SessionDataRejectDeliveryType = number | null;
export type SessionDataRejectDeliveryType = DeliveryType | null;

View File

@ -0,0 +1,21 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* negotiation.sessions.status . .
*/
export type SessionStatus = typeof SessionStatus[keyof typeof SessionStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const SessionStatus = {
CREATED: 1,
IN_PROGRESS: 2,
DONE: 3,
NOT_PARTICIPATED: 4,
REJECTED: 5,
} as const;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* users.role .
*/
export type UserRole = typeof UserRole[keyof typeof UserRole];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const UserRole = {
USER: 1,
MANAGER: 2,
} as const;

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, DragEvent, ChangeEvent } from 'react';
import React, { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react';
import { Upload, Image as ImageIcon, X, AlertCircle, Link2, Loader2 } from 'lucide-react';
interface ImageDropzoneProps {
@ -31,6 +31,12 @@ export default function ImageDropzone({
const [urlDraft, setUrlDraft] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// 현재 값이 일반 URL 이면 입력칸에 그대로 노출(보기/수정/복사 가능).
// base64 data URL(파일 업로드 폴백)은 거대 문자열이라 칸엔 넣지 않는다.
useEffect(() => {
setUrlDraft(value && /^https?:\/\//i.test(value) ? value : '');
}, [value]);
const processFile = async (file: File) => {
setError(null);
@ -107,8 +113,7 @@ export default function ImageDropzone({
const url = urlDraft.trim();
if (!url) return;
setError(null);
onChange(url);
setUrlDraft('');
onChange(url); // value 변경 → 위 effect 가 입력칸을 적용된 URL 로 다시 채움
};
return (

View File

@ -97,10 +97,7 @@ export function DataTable<T>({
const detailCols = mobileCols.filter((c) => c !== primaryCol)
return (
// 전환 기준은 뷰포트가 아니라 '표가 들어갈 실제 폭'(컨테이너). 사이드바가 폭을 먹어도
// 어긋나지 않는다. 컨테이너 ≥ 48rem(@3xl)이면 표, 그 미만은 카드 리스트. (Tailwind v4 내장 @container)
<div className={cn("@container border border-border rounded-lg bg-card overflow-hidden", className)}>
{/* 넓을 때: 표 — 그래도 넘치면 Table 내부에서 가로 스크롤 */}
<div className="hidden @3xl:block">
<Table className="w-full text-xs">
<TableHeader>
@ -184,7 +181,6 @@ export function DataTable<T>({
</Table>
</div>
{/* 좁을 때(컨테이너 < 48rem): 카드 리스트 — 행=카드, 컬럼=라벨:값 */}
<div className="@3xl:hidden divide-y divide-border">
{data.length > 0 ? (
data.map((row) => {
@ -218,7 +214,6 @@ export function DataTable<T>({
</div>
)}
{/* 상세: 기본=라벨:값 한 줄(컴팩트) / mobileBlock=라벨 아래 풀폭 */}
{detailCols.length > 0 && (
<dl className="mt-2.5 space-y-1.5 border-t border-border/40 pt-2.5 text-[11px] leading-tight">
{detailCols.map((c, i) =>

View File

@ -7,6 +7,8 @@ import {
import type {ResMe} from '../../api/generated/model/resMe';
import type {ErrorInfo} from '../../api/generated/model/errorInfo';
import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
import {UserRole as UserRoleCode} from '../../api/generated/model';
import {USER_ROLE_LABEL} from '../../lib/enumLabels';
const ACCESS_KEY = 'negodata.accessToken';
const REFRESH_KEY = 'negodata.refreshToken';
@ -26,7 +28,7 @@ function toAuthUser(me: ResMe): AuthUser {
loginId: me.id ?? '',
email: me.email ?? '',
contact: me.contact_number ?? '',
role: (me.role_label as UserRole) || '일반',
role: (USER_ROLE_LABEL[me.role ?? UserRoleCode.USER] ?? '일반') as UserRole,
};
}

View File

@ -54,10 +54,11 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
},
{
header: '스크립트',
headClassName: 'w-[22rem]', // 컬럼 폭 고정 → 긴 스크립트가 표를 늘리지 않게
cellClassName: 'font-mono text-muted-foreground',
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
cell: (card) => (
<div className="line-clamp-1 bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
<div className="max-w-[22rem] truncate bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
{card.scriptPreview}
</div>
),

View File

@ -1,27 +0,0 @@
import { useState } from 'react';
import type { NegotiationCard, CardTab } from '../types';
// 카드 목록의 탭(전체/협상/와일드) + 검색 필터 state와 파생 결과/카운트.
export function useCardFilters(cards: NegotiationCard[]) {
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<CardTab>('ALL');
const filtered = cards.filter((card) => {
const matchesTab =
activeTab === 'ALL' || (activeTab === 'WILD' ? card.isWildcard : !card.isWildcard);
const q = search.toLowerCase();
const matchesSearch =
card.title.toLowerCase().includes(q) ||
card.code.toLowerCase().includes(q) ||
card.scriptPreview.toLowerCase().includes(q);
return matchesTab && matchesSearch;
});
const counts = {
all: cards.length,
card: cards.filter((c) => !c.isWildcard).length,
wild: cards.filter((c) => c.isWildcard).length,
};
return { search, setSearch, activeTab, setActiveTab, filtered, counts };
}

View File

@ -1,11 +1,11 @@
import { useQueryClient } from '@tanstack/react-query';
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import {
useListCards,
createCard,
updateCard,
deleteCard,
getListCardsQueryKey,
} from '@/api/generated/card/card';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
import type { Descendant } from 'slate';
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
import type { ResCard } from '@/api/generated/model/resCard';
@ -13,8 +13,6 @@ import type { NegotiationCard } from '@/types';
import { mapCardData, toCardStatusCode } from '../types';
import { serializeToText } from '../editor';
const LIST_PARAMS = { size: 100 };
// 카드 폼이 넘기는 입력값(편집/생성 공통).
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
export type CardInput = {
@ -50,12 +48,13 @@ function toReq(input: CardInput): ReqCreateCard {
// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리.
export function useCards() {
export function useCards(params: ListCardsParams) {
const queryClient = useQueryClient();
const cardsQuery = useListCards(LIST_PARAMS);
// 테이블용(현재 페이지). 페이지 이동 시 placeholderData 로 이전 데이터 유지(깜빡임 방지).
const cardsQuery = useListCards(params, { query: { placeholderData: keepPreviousData } });
const refresh = () =>
queryClient.invalidateQueries({ queryKey: getListCardsQueryKey(LIST_PARAMS) });
// 변경 후 모든 카드 목록 쿼리(파라미터별 키 전부) 재조회.
const refresh = () => queryClient.invalidateQueries({ queryKey: ['/v1/card/list'] });
const createCardFn = async (input: CardInput) => {
const msg = cardError(await createCard(toReq(input)));
@ -74,9 +73,15 @@ export function useCards() {
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션)
const totalNego = cardsQuery.data?.total_nego ?? 0; // 협상카드 탭 카운트
const totalWild = cardsQuery.data?.total_wild ?? 0; // 와일드카드 탭 카운트
return {
cards,
total,
totalNego,
totalWild,
createCard: createCardFn,
updateCard: updateCardFn,
deleteCard: deleteCardFn,

View File

@ -1,18 +1,16 @@
import type { NegotiationCard } from '@/types';
import type { CardData } from '@/api/generated/model/cardData';
import { CardStatus } from '@/api/generated/model';
export type { NegotiationCard };
// 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드.
export type CardTab = 'ALL' | 'CARD' | 'WILD';
// 카드 status 코드(서버 CardStatus enum) ↔ UI 문자열. ACTIVE=1 / INACTIVE=2.
export const CARD_STATUS_ACTIVE = 1;
export const CARD_STATUS_INACTIVE = 2;
export const toCardStatusCode = (s: 'ACTIVE' | 'INACTIVE') =>
s === 'ACTIVE' ? CARD_STATUS_ACTIVE : CARD_STATUS_INACTIVE;
s === 'ACTIVE' ? CardStatus.ACTIVE : CardStatus.INACTIVE;
export const toCardStatusLabel = (code?: number): 'ACTIVE' | 'INACTIVE' =>
code === CARD_STATUS_INACTIVE ? 'INACTIVE' : 'ACTIVE';
code === CardStatus.INACTIVE ? 'INACTIVE' : 'ACTIVE';
// 서버 CardData(nego_cards/wild_cards 통합) → UI NegotiationCard.
export function mapCardData(c: CardData): NegotiationCard {

View File

@ -27,9 +27,7 @@ function supplierError(res: ResSupplier): string | null {
return r.desc || '협력사 등록에 실패했습니다.';
}
// 협력사 서버 데이터 + CRUD.
// - params: 테이블용 서버 페이지네이션/검색/우선순위 (useServerList 가 만든다)
// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리.
export function usePartners(params: ListSuppliersParams) {
const queryClient = useQueryClient();

View File

@ -1,11 +1,4 @@
import type { SupplierData } from '@/api/generated/model/supplierData';
// UI에서 쓰는 협력사 타입. 서버 SupplierData에 화면 전용 파생 필드만 얹는다.
// (level/rank/status 등 서버 미연동 가짜 필드는 두지 않는다 — 표시 가능한 건 priority뿐.)
export type Partner = SupplierData & {
deleted?: boolean;
id?: string;
};
export type { Partner } from '@/types';
// 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외).
export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW'];

View File

@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
import type { ReqUpdateItem as ItemUpdate } from '@/api/generated/model/reqUpdateItem';
import { useListEnums } from '@/api/generated/enums/enums';
import { DELIVERY_TYPE_OPTIONS } from '@/lib/enumLabels';
import { uploadItemImage } from '@/api/generated/item/item';
import { showToast } from '@/lib/notify';
import ImageDropzone from '@/components/ImageDropzone';
@ -121,9 +121,7 @@ export function ProductFormSheet({
defaultValues: buildDefaults(mode, product),
});
// 배송 형태 코드(delivery_type) 선택지는 서버 enum 에서 가져온다.
const { data: enumsData } = useListEnums();
const deliveryTypes = enumsData?.enums?.delivery_type ?? [];
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
// minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로.
const onValid = async (v: FormValues) => {

View File

@ -33,7 +33,7 @@ export function ProductTable({
rowKey={(prod) => prod.item_id}
onRowClick={onRowClick}
selection={{ selectedKeys: selectedIds, onSelectionChange }}
empty="부합하는 B2B 상품 데이터 정보가 식별되지 않습니다."
empty="부합하는 상품 데이터 정보가 식별되지 않습니다."
footer={
<TablePagination
page={page}

View File

@ -1,14 +1,4 @@
import type { ItemData } from '@/api/generated/model/itemData';
// UI에서 쓰는 상품 타입. 서버 ItemData에 화면 전용 파생 필드를 얹는다.
export type Product = ItemData & {
deleted?: boolean;
id?: string;
minPrice?: number;
status?: string;
};
// 분류 카테고리는 서버 items 에서 distinct 로 파생한다(useProducts). 하드코딩 상수 제거됨.
export type { Product } from '@/types';
// 인터넷 최저가 데모 산정(표준 단가의 83%). 서버 미연동 — 표시/초기값 용도.
export const toMinPrice = (price?: number | null) => Math.round((price || 0) * 0.83);

View File

@ -6,8 +6,10 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model';
import { QUOTATION_TYPE_OPTIONS } from '../types';
type CreateQuotationWizardProps = {
type QuotationCreateModalProps = {
open: boolean;
products: Product[];
partners: Partner[];
@ -17,7 +19,7 @@ type CreateQuotationWizardProps = {
onClose: () => void;
};
export function CreateQuotationWizard({
export function QuotationCreateModal({
open,
products,
partners,
@ -25,22 +27,25 @@ export function CreateQuotationWizard({
quotationSettings,
onCreate,
onClose,
}: CreateQuotationWizardProps) {
}: QuotationCreateModalProps) {
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
const [type, setType] = useState<'RE_NEGOTIATION' | 'RE_ESTIMATE'>('RE_NEGOTIATION');
const [type, setType] = useState<number>(QuotationType.REQUOTE);
const [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState('2026-06-15T18:00');
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const typeOptions = QUOTATION_TYPE_OPTIONS;
if (!open) return null;
const togglePartner = (id: string) =>
setSelectedPartnerIds((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id],
type === QuotationType.RENEGO
? prev.includes(id) ? [] : [id]
: prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id],
);
const toggleCard = (id: string) =>
setSelectedCardIds((prev) =>
@ -73,8 +78,8 @@ export function CreateQuotationWizard({
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
<span className="text-sm font-semibold text-foreground font-mono"> </span>
<span className="text-[11px] text-muted-foreground font-mono"> · </span>
<Typography variant="small" className="font-semibold font-mono"> </Typography>
<Typography variant="small" className="text-muted-foreground font-mono"> · </Typography>
</div>
</div>
)}
@ -84,7 +89,7 @@ export function CreateQuotationWizard({
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<span className="text-sm font-bold text-foreground"> ( {step}/3)</span>
<Typography variant="small" className="font-bold"> ( {step}/3)</Typography>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
@ -93,11 +98,11 @@ export function CreateQuotationWizard({
{/* Steps indicator */}
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-[10px] text-muted-foreground">
<span className={`font-semibold ${step >= 1 ? 'text-primary' : ''}`}>1. </span>
<Typography as="span" variant="label" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. </Typography>
<ArrowRight size={10} />
<span className={`font-semibold ${step >= 2 ? 'text-primary' : ''}`}>2. </span>
<Typography as="span" variant="label" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. </Typography>
<ArrowRight size={10} />
<span className={`font-semibold ${step >= 3 ? 'text-primary' : ''}`}>3. </span>
<Typography as="span" variant="label" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. </Typography>
</div>
{/* Step content */}
@ -121,17 +126,22 @@ export function CreateQuotationWizard({
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Select
value={type}
onValueChange={(v) => setType(v as 'RE_NEGOTIATION' | 'RE_ESTIMATE')}
value={String(type)}
onValueChange={(v) => {
const next = Number(v);
setType(next);
if (next === QuotationType.RENEGO) setSelectedPartnerIds((prev) => prev.slice(0, 1));
}}
>
<SelectTrigger id="wizard-type" className="w-full">
<SelectValue>
{(value) => (value === 'RE_ESTIMATE' ? '재견적' : '재협상')}
{(value) => typeOptions.find((o) => String(o.value) === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="RE_NEGOTIATION"></SelectItem>
<SelectItem value="RE_ESTIMATE"></SelectItem>
{typeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -175,7 +185,7 @@ export function CreateQuotationWizard({
{step === 2 && (
<div className="space-y-3">
<span className="font-semibold text-foreground block"> ()</span>
<Typography as="span" variant="small" className="font-semibold block"> ({type === QuotationType.RENEGO ? '단일선택' : '다중선택'})</Typography>
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
{partners.map((part) => {
const isChecked = selectedPartnerIds.includes(part.id ?? '');
@ -192,8 +202,8 @@ export function CreateQuotationWizard({
className="accent-primary h-4 w-4"
/>
<div>
<span className="font-semibold text-foreground block">{part.name}</span>
<span className="text-[10px] text-muted-foreground">: {part.managerEmail} · : {part.rank}</span>
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">: {part.managerEmail} · : {part.rank}</Typography>
</div>
</div>
<span
@ -236,7 +246,7 @@ export function CreateQuotationWizard({
</div>
<div className="space-y-2">
<span className="font-semibold text-foreground block"> </span>
<Typography as="span" variant="small" className="font-semibold block"> </Typography>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
const isChecked = selectedCardIds.includes(card.id);
@ -251,7 +261,7 @@ export function CreateQuotationWizard({
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
<div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono block leading-none">{card.code}</span>
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
@ -260,7 +270,7 @@ export function CreateQuotationWizard({
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<span className="text-xs text-foreground mt-1 block leading-tight">{card.title}</span>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
</div>
);

View File

@ -1,741 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router';
import {
StopCircle,
X,
UserCheck,
MessageSquare,
Layers,
Sparkles,
Package,
ExternalLink,
Copy,
} from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import SlateRenderer from '@/components/SlateRenderer';
import {
useGetQuotationSessions,
useGetSessionChat,
useGetQuotationCards,
} from '@/api/generated/quotation/quotation';
import {
type Estimate,
type Product,
type Partner,
type QuotationSetting,
normalizeQuotationStatus,
buildBidSummary,
mapServerSessionView,
sessionStatusLabel,
mapServerCardView,
} from '../types';
type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailDrawerProps = {
estimate: Estimate;
products: Product[];
partners: Partner[];
quotationSettings: QuotationSetting[];
onStop: (id: string, name: string) => void;
onClose: () => void;
};
export function QuotationDetailDrawer({
estimate,
products,
partners,
quotationSettings,
onStop,
onClose,
}: QuotationDetailDrawerProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true);
const qtId = estimate.id ?? '';
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } });
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
const serverSessions = sessionsQuery.data?.sessions ?? [];
const serverCards = cardsQuery.data?.cards ?? [];
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
query: { enabled: !!effectiveSessionId },
});
const chatMessages = chatQuery.data?.messages ?? [];
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
const currentSupplierName =
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
// 현재 세션의 상품(이미지·규격 등 상세 + 카드 변수 치환용 상품명).
const currentProduct = products.find((p) => p.id === currentSession?.item_id);
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) => (b == null ? '-' : b ? yes : no);
const productSpecRows = currentProduct
? [
{ label: '상품코드', value: currentProduct.code || '-' },
{ label: '단가', value: currentProduct.price != null ? `${Number(currentProduct.price).toLocaleString()}` : '-' },
{ label: '모델명', value: currentProduct.model_name || '-' },
{ label: '규격', value: currentProduct.spec || '-' },
{ label: '제조사', value: currentProduct.manufacturer || '-' },
{ label: '원산지', value: currentProduct.made_in || '-' },
{ label: 'MOQ', value: currentProduct.moq || '-' },
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}` : '-' },
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
]
: [];
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === estimate.settingApplied);
const bidSummaryObj = buildBidSummary(estimate, partners);
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products));
const quotationCardViews = serverCards.map(mapServerCardView);
// Quotations DDL 표시값
const q_name = estimate.name || estimate.title || '미지정';
const q_number = estimate.number || 'EST-000000-0000';
const q_type = estimate.type || '1:1';
const q_round = estimate.round || 1;
const q_status = estimate.status || '견적생성';
const q_end_time = estimate.end_time || estimate.dueDate || '미지정';
const q_manager_name = estimate.manager_name || '홍길동 파트너';
const q_manager_email = estimate.manager_email || 'gildong@negodata.com';
const q_memo = estimate.memo || '안내사항 없음';
const statusKey = normalizeQuotationStatus(q_status);
const goToChat = (sessionId: string) => {
setSelectedSessionId(sessionId);
setActiveTab('chat');
};
const tabs: { id: DrawerTab; label: string; icon: typeof UserCheck }[] = [
{ id: 'status', label: '협상 현황', icon: UserCheck },
{ id: 'chat', label: '협상 대화', icon: MessageSquare },
{ id: 'cards', label: `협상 카드 (${serverCards.length})`, icon: Layers },
];
return (
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl overflow-hidden animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-border bg-muted/30">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
<span>B2B // {q_number}</span>
</div>
<Typography variant="h3" className="mt-1">{q_name}</Typography>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHeaderCards(!showHeaderCards)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted hover:bg-muted-foreground/15 text-foreground border border-border rounded text-xs font-semibold cursor-pointer transition-colors"
>
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
</button>
{statusKey === '견적진행중' && (
<button
onClick={() => onStop(estimate.id ?? '', q_name)}
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors"
>
<StopCircle size={14} />
<span></span>
</button>
)}
<button
onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
>
<X size={20} />
</button>
</div>
</div>
{/* DB mapping info cards */}
{showHeaderCards && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
<div className="space-y-4">
{/* Quotations */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
</span>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold font-sans">{q_name}</span>
</div>
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold">{q_number}</span>
</div>
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold">
{q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'}
</span>
</div>
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold">{q_round}</span>
</div>
<div>
<span className="text-[10px] block opacity-90 font-bold mb-1"></span>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] font-bold rounded-full border shadow-2xs ${
statusKey === '견적생성'
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse'
: statusKey === '견적진행중'
? 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50'
: statusKey === '견적마감'
? 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50'
: statusKey === '협상보류'
? 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50'
: 'bg-zinc-100 text-zinc-800 border-zinc-300'
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
statusKey === '견적생성'
? 'bg-amber-500'
: statusKey === '견적진행중'
? 'bg-emerald-500'
: statusKey === '견적마감'
? 'bg-blue-500'
: statusKey === '협상보류'
? 'bg-rose-500'
: 'bg-zinc-500'
}`}
/>
{statusKey || q_status}
</span>
</div>
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold">{q_end_time}</span>
</div>
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold font-sans">{q_manager_name} ({q_manager_email})</span>
</div>
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold font-sans truncate block" title={q_memo || ''}>
{q_memo}
</span>
</div>
</div>
</div>
{/* Bid Summary */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
/
</span>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold">{bidSummaryObj.bid_summary_id}</span>
</div>
<div>
<span className="text-[10px] block opacity-70">/ </span>
<span className="text-foreground font-semibold">{bidSummaryObj.status}</span>
</div>
<div>
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold">{bidSummaryObj.qt_iteration}</span>
</div>
<div>
<span className="text-[10px] block opacity-70"> </span>
<span className={`text-foreground font-semibold ${bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : ''}`}>
{bidSummaryObj.has_preferred ? '존재' : '미존재'}
</span>
</div>
<div className="col-span-2">
<span className="text-[10px] block opacity-70"></span>
<span className="text-foreground font-semibold font-sans">{bidSummaryObj.preferred_sp_name}</span>
</div>
<div className="col-span-2">
<span className="text-[10px] block opacity-70"></span>
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
{bidSummaryObj.equal_data}
</code>
</div>
</div>
</div>
</div>
{/* 우측 컬럼: 상품정보 + 세팅 */}
<div className="space-y-4">
{/* 상품 정보 (협상 대상 상품) */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
</span>
{currentProduct ? (
<div className="flex gap-4">
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
{currentProduct.image_url ? (
<img
src={currentProduct.image_url}
alt={currentProduct.name || '상품'}
className="h-full w-full object-cover"
/>
) : (
<Package size={28} className="text-muted-foreground/50" />
)}
</div>
<div className="flex-1 min-w-0">
<Link
to={`/products?edit=${currentProduct.id}`}
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
>
{currentProduct.name || '-'}
</Link>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productSpecRows.map((r) => (
<div key={r.label}>
<span className="text-[10px] block opacity-70">{r.label}</span>
<span className="text-foreground font-semibold font-sans truncate block" title={r.value}>
{r.value}
</span>
</div>
))}
</div>
</div>
</div>
) : (
<div className="text-muted-foreground py-6 text-center"> .</div>
)}
</div>
{/* Quotation Settings */}
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
</span>
{selectedSettingObj ? (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<div>
<span className="text-[10px] block opacity-70"> </span>
<span className="text-foreground font-bold text-emerald-600 dark:text-emerald-400 font-sans">{selectedSettingObj.target_margin}</span>
</div>
<div>
<span className="text-[10px] block opacity-70"> </span>
<span className="text-foreground font-semibold font-sans">{selectedSettingObj.anchoring_value}</span>
</div>
<div className="col-span-2 col-start-1">
<span className="text-[10px] block opacity-70"> </span>
<span className="text-foreground font-semibold">{selectedSettingObj.card_use_count}</span>
</div>
</div>
) : (
<div className="text-muted-foreground py-6 text-center"> .</div>
)}
</div>
</div>
</div>
)}
</div>
{/* Tabs */}
<div className="border-b border-border bg-background px-6">
<div className="flex gap-4">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
if (tab.id === 'chat') {
// 협상 대화 탭에선 대화 영역을 넓게 쓰도록 견적 상세 정보를 접는다.
setShowHeaderCards(false);
if (serverSessions.length > 0 && !selectedSessionId) {
setSelectedSessionId(serverSessions[0].session_id);
}
}
}}
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
activeTab === tab.id
? 'border-primary text-primary font-bold'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon size={14} />
<span>{tab.label}</span>
</button>
);
})}
</div>
</div>
{/* Tab content */}
<div className="flex-1 p-6 overflow-y-auto bg-background/50">
{/* Tab: Sessions Status */}
{activeTab === 'status' && (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold"> ID</TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"> URL</TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead>
<TableHead className="p-3 font-semibold"></TableHead>
<TableHead className="p-3 font-semibold"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
)}
{sessionViews.map((sess) => (
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground font-mono">{sess.session_id}</TableCell>
<TableCell className="p-3 font-bold text-foreground font-sans">
<div className="flex items-center gap-2">
<span>{sess.supplier_name}</span>
<button
onClick={() => goToChat(sess.session_id)}
title="협상 대화방으로 이동"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
<MessageSquare size={13} />
</button>
</div>
</TableCell>
<TableCell className="p-3 font-mono">
{sess.url ? (
<div className="flex items-center gap-1.5">
<a
href={sess.url}
target="_blank"
rel="noopener noreferrer"
title={sess.url}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
>
<ExternalLink size={11} />
</a>
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center">
<span
className={`inline-flex px-2 py-0.5 rounded-full text-[10px] font-bold ${
sess.status === '협상완료' || sess.status === 'COMPLETED'
? 'bg-blue-100 text-blue-800 dark:bg-blue-950/20 dark:text-blue-300'
: sess.status === '협상거부' || sess.status === 'REJECTED'
? 'bg-red-100 text-red-800 dark:bg-red-950/20 dark:text-red-300'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/20 dark:text-emerald-300'
}`}
>
{sess.status}
</span>
</TableCell>
<TableCell className="p-3 text-right font-bold text-muted-foreground">
{sess.target_price?.toLocaleString() || '-'}
</TableCell>
<TableCell className="p-3 text-right font-bold text-foreground">
{sess.bid_price ? `${sess.bid_price.toLocaleString()}` : '-'}
</TableCell>
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
<TableCell className="p-3 text-right text-rose-600 font-mono">{sess.reject_price ? `${sess.reject_price.toLocaleString()}` : '-'}</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Tab: Quotation Cards */}
{activeTab === 'cards' && (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-hidden">
<Table className="w-full text-left text-xs border-collapse font-mono">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold"> ID</TableHead>
<TableHead className="p-3 font-semibold font-sans"> </TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{quotationCardViews.length > 0 ? (
quotationCardViews.map((qc) => (
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground">{qc.session_card_id}</TableCell>
<TableCell className="p-3 text-foreground font-sans font-semibold">
{qc.card_id ? (
<Link
to={`/cards?edit=${qc.card_id}`}
className="hover:text-primary hover:underline"
title={`${qc.card_name} — 협상카드 상세로 이동`}
>
{qc.card_name}
</Link>
) : (
qc.card_name
)}
</TableCell>
<TableCell className="p-3">
<span
className={`inline-flex px-2 py-0.5 rounded text-[10px] font-bold ${
qc.type === '와일드 카드'
? 'bg-amber-100 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300'
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800/40 dark:text-zinc-300'
}`}
>
{qc.type}
</span>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
{/* Tab: Chat */}
{activeTab === 'chat' && (
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
{/* Sessions list */}
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase">
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border font-sans">
{serverSessions.length === 0 && (
<div className="p-4 text-center text-muted-foreground text-xs font-mono">
. ( )
</div>
)}
{serverSessions.map((sd) => {
const isSelected = sd.session_id === effectiveSessionId;
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
const statusLabel = sessionStatusLabel(sd.status);
return (
<button
key={sd.session_id}
onClick={() => setSelectedSessionId(sd.session_id)}
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
isSelected ? 'bg-primary/5 border-l-4 border-primary' : 'hover:bg-muted/30'
}`}
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground text-xs">{name}</span>
<span
className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${
statusLabel === '협상거부'
? 'bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300'
: statusLabel === '협상완료'
? 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-300'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300'
}`}
>
{statusLabel}
</span>
</div>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground mt-2">
<span> </span>
<span className="font-bold text-foreground">
{sd.bid_price ? `${Number(sd.bid_price).toLocaleString()}` : '-'}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Chat zone */}
<div className="flex-1 flex flex-col bg-background justify-between">
<div className="p-3 bg-muted/30 border-b border-border text-xs flex items-center justify-between font-mono">
<div className="text-muted-foreground">
: <strong className="text-foreground">{currentSupplierName}</strong>
</div>
<div className="text-xs text-muted-foreground">
: <span className="font-semibold text-foreground">{chatMessages.length}</span>
</div>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4">
{!effectiveSessionId ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
.
</div>
) : chatMessages.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
.
</div>
) : (
chatMessages.map((m) => {
const isBot = m.sender === 1;
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
const usedCard = m.card_used_yn
? serverCards.find((c) => c.session_card_id === m.chat_id)
: undefined;
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
const isWildCard = usedCard?.type === 2;
return (
<div
key={m.chat_id}
className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}
>
<div className="space-y-1 max-w-[85%]">
<div className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${isBot ? '' : 'justify-end'}`}>
<span>{isBot ? 'Negosium Bot' : currentSupplierName}</span>
<span>·</span>
<span>#{m.index}</span>
</div>
<div
className={`p-3 rounded-md border text-xs shadow-xs ${
isBot ? 'bg-secondary border-border text-foreground' : 'bg-primary border-transparent text-primary-foreground'
}`}
>
<div className="font-bold"> {Number(m.target_price).toLocaleString()}</div>
{usedCard && (
<div
className={`mt-2 rounded border p-2 ${
isBot
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
: 'bg-white/10 border-white/20'
}`}
>
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
<div
className={`flex items-center gap-1 text-[10px] font-semibold ${
isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'
}`}
>
<Sparkles size={10} />
<span></span>
{usedCard.number && <span className="font-mono opacity-70">#{usedCard.number}</span>}
{usedCard.name && <span>· {usedCard.name}</span>}
<span
className={`ml-auto px-1.5 py-0.5 rounded font-bold ${
isWildCard
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
}`}
>
{isWildCard ? '와일드' : '협상'}
</span>
</div>
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
{cardNodes ? (
<div className="mt-1.5">
<SlateRenderer
nodes={cardNodes}
variables={{
target_price: m.target_price,
partner_name: currentSupplierName,
product_name: currentProduct?.name ?? '',
}}
/>
</div>
) : usedCard.script ? (
<p className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{usedCard.script}
</p>
) : null}
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
{isWildCard && (usedCard.condition || usedCard.memo) && (
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-[10px] text-muted-foreground">
{usedCard.condition && (
<div>
<span className="font-semibold">:</span> {usedCard.condition}
</div>
)}
{usedCard.memo && (
<div>
<span className="font-semibold">:</span> {usedCard.memo}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
<div className="p-3 border-t border-border bg-muted/20 flex gap-2">
<Input
type="text"
disabled
placeholder="이 대화방은 입찰 참여 세션 기록이므로 정독 전용입니다."
className="flex-1 text-xs text-muted-foreground"
/>
<button
disabled
className="py-1.5 px-3 bg-muted text-muted-foreground text-xs rounded border border-border cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,236 @@
import { Sparkles } from 'lucide-react';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
import { ChatSender, CardType } from '@/api/generated/model';
import { Input } from '@/components/ui/input';
import SlateRenderer from '@/components/SlateRenderer';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { type Product, type Partner, sessionStatusLabel } from '../../types';
export function ChatTab({
serverSessions,
partners,
effectiveSessionId,
onSelectSession,
chatMessages,
currentSupplierName,
currentProduct,
serverCards,
}: {
serverSessions: SessionData[];
partners: Partner[];
effectiveSessionId: string | null;
onSelectSession: (sessionId: string) => void;
chatMessages: ChatMessageData[];
currentSupplierName: string;
currentProduct: Product | undefined;
serverCards: QuotationCardData[];
}) {
// 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다.
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
const targetPrice = currentSession?.target_price;
return (
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
{/* Sessions list */}
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase">
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border font-sans">
{serverSessions.length === 0 && (
<div className="p-4 text-center text-muted-foreground text-xs font-mono">
. ( )
</div>
)}
{serverSessions.map((sd) => {
const isSelected = sd.session_id === effectiveSessionId;
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
const statusLabel = sessionStatusLabel(sd.status);
return (
<button
key={sd.session_id}
onClick={() => onSelectSession(sd.session_id)}
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
isSelected ? 'bg-primary/5 border-l-4 border-primary' : 'hover:bg-muted/30'
}`}
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground text-xs">{name}</span>
<StatusPill tone={sessionStatusTone(sd.status)} className="text-[9px] px-1.5 rounded">
{statusLabel}
</StatusPill>
</div>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground mt-2">
<span> </span>
<span className="font-bold text-foreground">
{sd.bid_price ? `${Number(sd.bid_price).toLocaleString()}` : '-'}
</span>
</div>
</button>
);
})}
</div>
</div>
{/* Chat zone */}
<div className="flex-1 flex flex-col bg-background justify-between">
<div className="p-3 bg-muted/30 border-b border-border text-xs flex items-center justify-between font-mono">
<div className="text-muted-foreground">
: <strong className="text-foreground">{currentSupplierName}</strong>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{targetPrice != null && (
<span>
: <strong className="text-foreground">{Number(targetPrice).toLocaleString()}</strong>
</span>
)}
<span>
: <span className="font-semibold text-foreground">{chatMessages.length}</span>
</span>
</div>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4">
{!effectiveSessionId ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
.
</div>
) : chatMessages.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
.
</div>
) : (
chatMessages.map((m) => {
const isBot = m.sender === ChatSender.BOT;
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
const usedCard = m.card_used_yn
? serverCards.find((c) => c.session_card_id === m.chat_id)
: undefined;
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
const isWildCard = usedCard?.type === CardType.WILD;
return (
<div key={m.chat_id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}>
<div className="space-y-1 max-w-[85%]">
<div
className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${
isBot ? '' : 'justify-end'
}`}
>
<span>{isBot ? 'Negosium Bot' : currentSupplierName}</span>
<span>·</span>
<span>#{m.index}</span>
</div>
<div
className={`p-3 rounded-md border text-xs shadow-xs ${
isBot
? 'bg-secondary border-border text-foreground'
: 'bg-primary border-transparent text-primary-foreground'
}`}
>
{/* 진행 단계(chats.meta.step). 주로 봇 턴에만 존재. */}
{m.step && (
<div className="text-[10px] font-mono uppercase tracking-wide opacity-60 mb-1">
{m.step}
</div>
)}
{/* 말풍선 멘트(chats.meta.script). 봇=협상 스크립트, 협력사=입력값. */}
{m.script && (
<p className="whitespace-pre-line leading-relaxed mb-1.5">{m.script}</p>
)}
{/* : (user) . .
(target_price=0) (0 ). */}
{!isBot && m.target_price > 0 && (
<div className="font-bold"> {Number(m.target_price).toLocaleString()}</div>
)}
{usedCard && (
<div
className={`mt-2 rounded border p-2 ${
isBot
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
: 'bg-white/10 border-white/20'
}`}
>
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
<div
className={`flex items-center gap-1 text-[10px] font-semibold ${
isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'
}`}
>
<Sparkles size={10} />
<span></span>
{usedCard.number && <span className="font-mono opacity-70">#{usedCard.number}</span>}
{usedCard.name && <span>· {usedCard.name}</span>}
<span
className={`ml-auto px-1.5 py-0.5 rounded font-bold ${
isWildCard
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
}`}
>
{isWildCard ? '와일드' : '협상'}
</span>
</div>
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
{cardNodes ? (
<div className="mt-1.5">
<SlateRenderer
nodes={cardNodes}
variables={{
target_price: m.target_price,
partner_name: currentSupplierName,
product_name: currentProduct?.name ?? '',
}}
/>
</div>
) : usedCard.script ? (
<p className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{usedCard.script}
</p>
) : null}
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
{isWildCard && (usedCard.condition || usedCard.memo) && (
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-[10px] text-muted-foreground">
{usedCard.condition && (
<div>
<span className="font-semibold">:</span> {usedCard.condition}
</div>
)}
{usedCard.memo && (
<div>
<span className="font-semibold">:</span> {usedCard.memo}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
<div className="p-3 border-t border-border bg-muted/20 flex gap-2">
<Input
type="text"
disabled
placeholder="이 대화방은 입찰 참여 세션 기록이므로 정독 전용입니다."
className="flex-1 text-xs text-muted-foreground"
/>
<button
disabled
className="py-1.5 px-3 bg-muted text-muted-foreground text-xs rounded border border-border cursor-not-allowed"
>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,190 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { Package } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { InfoField } from './InfoField';
import { QuotationStatusBadge } from './StatusPill';
import type { QuotationData } from '@/api/generated/model/quotationData';
import {
type Product,
type Partner,
type QuotationSetting,
buildBidSummary,
quotationTypeLabel,
} from '../../types';
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
b == null ? '-' : b ? yes : no;
/** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
return (
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
{title}
</span>
{children}
</Card>
);
}
type DrawerHeaderCardsProps = {
quotation: QuotationData;
partners: Partner[];
quotationSettings: QuotationSetting[];
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
currentProduct: Product | undefined;
};
export function DrawerHeaderCards({
quotation,
partners,
quotationSettings,
currentProduct,
}: DrawerHeaderCardsProps) {
// Quotations DDL 표시값
const q_name = quotation.name || '미지정';
const q_number = quotation.number || 'EST-000000-0000';
const q_round = quotation.round || 1;
const q_end_time = quotation.end_time || '미지정';
const q_manager_name = quotation.manager_name || '홍길동 파트너';
const q_manager_email = quotation.manager_email || 'gildong@negodata.com';
const q_memo = quotation.memo || '안내사항 없음';
const bidSummaryObj = buildBidSummary(quotation, partners);
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
const productSpecRows = currentProduct
? [
{ label: '상품코드', value: currentProduct.code || '-' },
{ label: '단가', value: currentProduct.price != null ? `${Number(currentProduct.price).toLocaleString()}` : '-' },
{ label: '모델명', value: currentProduct.model_name || '-' },
{ label: '규격', value: currentProduct.spec || '-' },
{ label: '제조사', value: currentProduct.manufacturer || '-' },
{ label: '원산지', value: currentProduct.made_in || '-' },
{ label: 'MOQ', value: currentProduct.moq || '-' },
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}` : '-' },
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
]
: [];
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
<div className="space-y-4">
{/* Quotations */}
<SectionCard title="견적 정보">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
<InfoField label="견적번호" value={q_number} />
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} />
<InfoField label="차수" value={`${q_round}`} />
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
<QuotationStatusBadge status={quotation.status} />
</InfoField>
<InfoField label="마감시각" value={q_end_time} />
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" />
<InfoField
label="메모"
value={q_memo}
valueClassName="font-sans truncate block"
title={q_memo || ''}
/>
</div>
</SectionCard>
{/* Bid Summary */}
<SectionCard title="견적 진행상태/결과">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField label="식별자" value={bidSummaryObj.bid_summary_id} />
<InfoField label="진행/결과 상태" value={bidSummaryObj.status} />
<InfoField label="반복횟수" value={`${bidSummaryObj.qt_iteration}`} />
<InfoField
label="우선협상자 존재여부"
value={bidSummaryObj.has_preferred ? '존재' : '미존재'}
valueClassName={bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : undefined}
/>
<InfoField
label="우선협상자명"
value={bidSummaryObj.preferred_sp_name}
className="col-span-2"
valueClassName="font-sans"
/>
<InfoField label="동가입찰정보" className="col-span-2">
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
{bidSummaryObj.equal_data}
</code>
</InfoField>
</div>
</SectionCard>
</div>
{/* 우측 컬럼: 상품정보 + 세팅 */}
<div className="space-y-4">
{/* 상품 정보 (협상 대상 상품) */}
<SectionCard title="상품 정보">
{currentProduct ? (
<div className="flex gap-4">
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
{currentProduct.image_url ? (
<img
src={currentProduct.image_url}
alt={currentProduct.name || '상품'}
className="h-full w-full object-cover"
/>
) : (
<Package size={28} className="text-muted-foreground/50" />
)}
</div>
<div className="flex-1 min-w-0">
<Link
to={`/products?detail=${currentProduct.id}`}
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
>
{currentProduct.name || '-'}
</Link>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productSpecRows.map((r) => (
<InfoField
key={r.label}
label={r.label}
value={r.value}
valueClassName="font-sans truncate block"
title={r.value}
/>
))}
</div>
</div>
</div>
) : (
<div className="text-muted-foreground py-6 text-center"> .</div>
)}
</SectionCard>
{/* Quotation Settings */}
<SectionCard title="견적 세팅">
{selectedSettingObj ? (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField
label="목표 마진율"
value={selectedSettingObj.target_margin}
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
/>
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
<InfoField
label="카드 사용 횟수"
value={selectedSettingObj.card_use_count}
className="col-span-2 col-start-1"
/>
</div>
) : (
<div className="text-muted-foreground py-6 text-center"> .</div>
)}
</SectionCard>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
type InfoFieldProps = {
label: string;
/** 단순 텍스트 값. 커스텀 마크업이 필요하면 value 대신 children 을 쓴다. */
value?: ReactNode;
children?: ReactNode;
className?: string;
labelClassName?: string;
valueClassName?: string;
title?: string;
};
/** 헤더 카드의 `라벨 / 값` 한 칸. (드로어 곳곳에서 ~20회 반복되던 패턴) */
export function InfoField({
label,
value,
children,
className,
labelClassName,
valueClassName,
title,
}: InfoFieldProps) {
return (
<div className={className}>
<span className={cn('text-[10px] block opacity-70', labelClassName)}>{label}</span>
{children ?? (
<span className={cn('text-foreground font-semibold', valueClassName)} title={title}>
{value}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,57 @@
import { Link } from 'react-router';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { StatusPill } from './StatusPill';
import { mapServerCardView } from '../../types';
type CardView = ReturnType<typeof mapServerCardView>;
export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: CardView[] }) {
return (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-hidden">
<Table className="w-full text-left text-xs border-collapse font-mono">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold"> ID</TableHead>
<TableHead className="p-3 font-semibold font-sans"> </TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{quotationCardViews.length > 0 ? (
quotationCardViews.map((qc) => (
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground">{qc.session_card_id}</TableCell>
<TableCell className="p-3 text-foreground font-sans font-semibold">
{qc.card_id ? (
<Link
to={`/cards?detail=${qc.card_id}`}
className="hover:text-primary hover:underline"
title={`${qc.card_name} — 협상카드 상세로 이동`}
>
{qc.card_name}
</Link>
) : (
qc.card_name
)}
</TableCell>
<TableCell className="p-3">
<StatusPill tone={qc.type === '와일드 카드' ? 'amber' : 'zinc'} className="rounded">
{qc.type}
</StatusPill>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,110 @@
import { MessageSquare, ExternalLink, Copy } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { mapServerSessionView, sessionStatusLabel } from '../../types';
type SessionView = ReturnType<typeof mapServerSessionView>;
export function SessionsStatusTab({
sessionViews,
onOpenChat,
}: {
sessionViews: SessionView[];
onOpenChat: (sessionId: string) => void;
}) {
return (
<div className="space-y-4">
<div className="border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold"> ID</TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"> URL</TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead>
<TableHead className="p-3 font-semibold"></TableHead>
<TableHead className="p-3 font-semibold"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>
)}
{sessionViews.map((sess) => (
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-muted-foreground font-mono">{sess.session_id}</TableCell>
<TableCell className="p-3 font-bold text-foreground font-sans">
<div className="flex items-center gap-2">
<span>{sess.supplier_name}</span>
<button
onClick={() => onOpenChat(sess.session_id)}
title="협상 대화방으로 이동"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
<MessageSquare size={13} />
</button>
</div>
</TableCell>
<TableCell className="p-3 font-mono">
{sess.url ? (
<div className="flex items-center gap-1.5">
<a
href={sess.url}
target="_blank"
rel="noopener noreferrer"
title={sess.url}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
>
<ExternalLink size={11} />
</a>
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center">
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</TableCell>
<TableCell className="p-3 text-right font-bold text-muted-foreground">
{sess.target_price?.toLocaleString() || '-'}
</TableCell>
<TableCell className="p-3 text-right font-bold text-foreground">
{sess.bid_price ? `${sess.bid_price.toLocaleString()}` : '-'}
</TableCell>
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
<TableCell className="p-3 text-right text-rose-600 font-mono">
{sess.reject_price ? `${sess.reject_price.toLocaleString()}` : '-'}
</TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { QuotationStatus, SessionStatus } from '@/api/generated/model';
import { quotationStatusLabel } from '../../types';
/* ── 작은 상태 pill (세션 상태 / / )
dark (/20·/30) red·rose
. . */
export type PillTone = 'blue' | 'rose' | 'emerald' | 'amber' | 'zinc';
const PILL_TONE: Record<PillTone, string> = {
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-300',
rose: 'bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300',
emerald: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300',
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300',
zinc: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800/40 dark:text-zinc-300',
};
export function StatusPill({
tone,
className,
children,
}: {
tone: PillTone;
className?: string;
children: ReactNode;
}) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold',
PILL_TONE[tone],
className,
)}
>
{children}
</span>
);
}
export function sessionStatusTone(status?: number | null): PillTone {
if (status === SessionStatus.DONE) return 'blue';
if (status === SessionStatus.REJECTED) return 'rose';
return 'emerald';
}
/* (, dot + border + pulse)
pill (··pulse) . */
const QSTATUS_TONE: Record<QuotationStatus, { box: string; dot: string }> = {
[QuotationStatus.CREATED]: {
box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse',
dot: 'bg-amber-500',
},
[QuotationStatus.ACTIVE]: {
box: 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50',
dot: 'bg-emerald-500',
},
[QuotationStatus.CLOSED]: {
box: 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50',
dot: 'bg-blue-500',
},
[QuotationStatus.ON_HOLD]: {
box: 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50',
dot: 'bg-rose-500',
},
};
const QSTATUS_FALLBACK = { box: 'bg-zinc-100 text-zinc-800 border-zinc-300', dot: 'bg-zinc-500' };
export function QuotationStatusBadge({ status }: { status?: number | null }) {
const t = (status != null && QSTATUS_TONE[status as QuotationStatus]) || QSTATUS_FALLBACK;
return (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] font-bold rounded-full border shadow-2xs',
t.box,
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', t.dot)} />
{quotationStatusLabel(status)}
</span>
);
}

View File

@ -0,0 +1,209 @@
import { useState } from 'react';
import { CheckCircle2, X, UserCheck, MessageSquare, Layers } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import {
useGetQuotationSessions,
useGetSessionChat,
useGetQuotationCards,
} from '@/api/generated/quotation/quotation';
import { useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
import type { QuotationData } from '@/api/generated/model/quotationData';
import {
mapItem,
mapSupplier,
mapSetting,
mapServerSessionView,
mapServerCardView,
} from '../../types';
import { QuotationStatus } from '@/api/generated/model';
import { DrawerHeaderCards } from './DrawerHeaderCards';
import { SessionsStatusTab } from './SessionsStatusTab';
import { QuotationCardsTab } from './QuotationCardsTab';
import { ChatTab } from './ChatTab';
type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailSheetProps = {
quotation: QuotationData;
onCloseQuotation: (id: string, name: string) => void;
onClose: () => void;
};
export function QuotationDetailSheet({
quotation,
onCloseQuotation,
onClose,
}: QuotationDetailSheetProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true);
// 협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
const suppliersQuery = useListSuppliers({ size: 100 });
const settingsQuery = useListSettings();
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
const quotationSettings = (
settingsQuery.data?.settings ?? []
).map(mapSetting);
const qtId = quotation.qt_id ?? '';
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } });
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
const serverSessions = sessionsQuery.data?.sessions ?? [];
const serverCards = cardsQuery.data?.cards ?? [];
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
query: { enabled: !!effectiveSessionId },
});
const chatMessages = chatQuery.data?.messages ?? [];
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
const currentSupplierName =
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
// 견적 1건 = 상품 1개(item_ids:[productId])라 모든 세션이 같은 상품을 공유한다.
// 카탈로그 전체 대신 그 상품 1건만 단건 조회 → 상품 수가 늘어도 무관하고, 상품 id 별로 캐시된다.
const itemId = serverSessions[0]?.item_id ?? '';
const itemQuery = useGetItem(itemId, { query: { enabled: !!itemId } });
const currentItem = itemQuery.data?.item;
// 이미지·규격 등 상세 + 카드 변수 치환용 상품명.
const currentProduct = currentItem ? mapItem(currentItem) : undefined;
// 세션은 모두 같은 상품을 가리키므로(1견적=1상품) 단건 상품 하나로 item_name 해석이 끝난다.
const productList = currentProduct ? [currentProduct] : [];
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, productList));
const quotationCardViews = serverCards.map(mapServerCardView);
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
const q_name = quotation.name || '미지정';
const q_number = quotation.number || 'EST-000000-0000';
const goToChat = (sessionId: string) => {
setSelectedSessionId(sessionId);
setActiveTab('chat');
};
const tabs: { id: DrawerTab; label: string; icon: typeof UserCheck }[] = [
{ id: 'status', label: '협상 현황', icon: UserCheck },
{ id: 'chat', label: '협상 대화', icon: MessageSquare },
{ id: 'cards', label: `협상 카드 (${serverCards.length})`, icon: Layers },
];
return (
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl overflow-hidden animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-border bg-muted/30">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
<span> // {q_number}</span>
</div>
<Typography variant="h3" className="mt-1">{q_name}</Typography>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHeaderCards(!showHeaderCards)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted hover:bg-muted-foreground/15 text-foreground border border-border rounded text-xs font-semibold cursor-pointer transition-colors"
>
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
</button>
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */}
{(() => {
const canClose = quotation.status !== QuotationStatus.CLOSED;
return (
<button
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
disabled={!canClose}
title={canClose ? undefined : '이미 마감된 견적입니다.'}
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600"
>
<CheckCircle2 size={14} />
<span> </span>
</button>
);
})()}
<button
onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
>
<X size={20} />
</button>
</div>
</div>
{/* DB mapping info cards */}
{showHeaderCards && (
<DrawerHeaderCards
quotation={quotation}
partners={partners}
quotationSettings={quotationSettings}
currentProduct={currentProduct}
/>
)}
</div>
{/* Tabs */}
<div className="border-b border-border bg-background px-6">
<div className="flex gap-4">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
if (tab.id === 'chat') {
// 협상 대화 탭에선 대화 영역을 넓게 쓰도록 견적 상세 정보를 접는다.
setShowHeaderCards(false);
if (serverSessions.length > 0 && !selectedSessionId) {
setSelectedSessionId(serverSessions[0].session_id);
}
}
}}
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
activeTab === tab.id
? 'border-primary text-primary font-bold'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon size={14} />
<span>{tab.label}</span>
</button>
);
})}
</div>
</div>
{/* Tab content */}
<div className="flex-1 p-6 overflow-y-auto bg-background/50">
{activeTab === 'status' && (
<SessionsStatusTab sessionViews={sessionViews} onOpenChat={goToChat} />
)}
{activeTab === 'cards' && <QuotationCardsTab quotationCardViews={quotationCardViews} />}
{activeTab === 'chat' && (
<ChatTab
serverSessions={serverSessions}
partners={partners}
effectiveSessionId={effectiveSessionId}
onSelectSession={setSelectedSessionId}
chatMessages={chatMessages}
currentSupplierName={currentSupplierName}
currentProduct={currentProduct}
serverCards={serverCards}
/>
)}
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,8 @@
import type { ReactNode } from 'react';
import { Clock, Building2 } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table';
import { type Estimate, type Product, normalizeQuotationStatus } from '../types';
import { type Estimate, type Product, quotationStatusLabel, quotationTypeLabel } from '../types';
import { QuotationType, QuotationStatus } from '@/api/generated/model';
type QuotationTableProps = {
data: Estimate[];
@ -10,15 +11,15 @@ type QuotationTableProps = {
footer?: ReactNode;
};
const statusBadgeClass = (status?: string | null) => {
switch (normalizeQuotationStatus(status)) {
case '견적생성':
const statusBadgeClass = (status?: number | null) => {
switch (status) {
case QuotationStatus.CREATED:
return 'bg-yellow-50 text-yellow-700 border-yellow-300 animate-pulse';
case '견적진행중':
case QuotationStatus.ACTIVE:
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40';
case '견적마감':
case QuotationStatus.CLOSED:
return 'bg-blue-50 text-blue-700 border-blue-300';
case '협상보류':
case QuotationStatus.ON_HOLD:
return 'bg-red-50 text-red-700 border-red-300';
default:
return 'bg-zinc-100 text-zinc-600';
@ -63,12 +64,12 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
cell: (est) => (
<span
className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${
est.type === 'RE_NEGOTIATION'
est.type === QuotationType.RENEGO
? 'bg-neutral-900 text-white dark:bg-zinc-100 dark:text-black'
: 'bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200'
}`}
>
{est.type === 'RE_NEGOTIATION' ? '재협상' : '재견적'}
{quotationTypeLabel(est.type)}
</span>
),
},
@ -85,7 +86,7 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${statusBadgeClass(est.status)}`}
>
{normalizeQuotationStatus(est.status) || est.status}
{quotationStatusLabel(est.status)}
</span>
),
},

View File

@ -1,29 +0,0 @@
import { useState } from 'react';
import { type Estimate, normalizeQuotationStatus } from '../types';
// 견적 목록의 검색/상태/유형 필터 state + 파생 결과.
export function useQuotationFilters(quotations: Estimate[]) {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('ALL');
const [typeFilter, setTypeFilter] = useState('ALL');
const filtered = quotations.filter((est) => {
const q = search.toLowerCase();
const matchesSearch =
(est.title || '').toLowerCase().includes(q) || (est.number || '').toLowerCase().includes(q);
const matchesStatus =
statusFilter === 'ALL' || normalizeQuotationStatus(est.status) === statusFilter;
const matchesType = typeFilter === 'ALL' || est.type === typeFilter;
return matchesSearch && matchesStatus && matchesType;
});
return {
search,
setSearch,
statusFilter,
setStatusFilter,
typeFilter,
setTypeFilter,
filtered,
};
}

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useListItems } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card';
@ -14,22 +14,20 @@ import {
useListQuotations,
useCreateQuotation,
useStopQuotation,
getListQuotationsQueryKey,
getGetQuotationQueryKey,
getGetQuotationSessionsQueryKey,
} from '@/api/generated/quotation/quotation';
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
import type { ItemData } from '@/api/generated/model/itemData';
import type { SupplierData } from '@/api/generated/model/supplierData';
import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData';
import type { QuotationData } from '@/api/generated/model/quotationData';
import type { CardData } from '@/api/generated/model/cardData';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import type { Estimate } from '@/types';
import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import type { Estimate } from '../types';
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import { QuotationStatus } from '@/api/generated/model';
export type CreateQuotationInput = {
title: string;
type: 'RE_NEGOTIATION' | 'RE_ESTIMATE';
type: number; // QuotationType 코드 (1=재협상, 2=재견적)
productId: string;
partnerIds: string[];
dueDate: string; // datetime-local 원본값
@ -46,53 +44,61 @@ export type SettingInput = {
// 견적 화면 데이터 허브.
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
export function useQuotations() {
export function useQuotations(params: ListQuotationsParams) {
const queryClient = useQueryClient();
const itemsQuery = useListItems({ size: 100 });
const suppliersQuery = useListSuppliers({ size: 100 });
const cardsQuery = useListCards({ size: 100 });
const settingsQuery = useListSettings();
const quotationsQuery = useListQuotations(undefined);
// 견적 목록은 서버 검색/상태·유형 필터/페이지네이션. 페이지 이동 시 이전 데이터 유지(깜빡임 방지).
const quotationsQuery = useListQuotations(params, { query: { placeholderData: keepPreviousData } });
const createSettingMutation = useCreateSetting();
const deleteSettingMutation = useDeleteSetting();
const createQuotationMutation = useCreateQuotation();
const stopQuotationMutation = useStopQuotation();
// 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화).
const invalidateQuotations = () =>
queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) });
queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] });
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
const products = (itemsQuery.data?.items ?? []).map(mapItem);
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
// 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다.
const quotationSettings = (
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
settingsQuery.data?.settings ?? []
).map(mapSetting);
const [quotations, setQuotations] = useState<Estimate[]>([]);
useEffect(() => {
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
const qs = quotationsQuery.data?.quotations;
if (qs) setQuotations(qs.map(mapQuotation));
}, [quotationsQuery.data]);
// 서버 전체 건수(선택 필터 반영) — 페이지네이션용.
const total = quotationsQuery.data?.total ?? 0;
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
const cards = (cardsQuery.data?.cards ?? []).map(mapCardData);
// 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화.
const stopNegotiation = async (id: string, name: string) => {
if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return;
// 견적 마감 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속 + 협상생성 세션은 미참여로 전이).
// 성공 시 목록 무효화로 서버값 재동기화.
const closeQuotation = async (id: string, name: string) => {
if (!(await confirm({ title: '견적 마감', description: `[${name}] 견적을 마감하시겠습니까? 마감하면 진행 중인 협상이 종료되고 되돌릴 수 없습니다.`, confirmText: '마감', destructive: true }))) return;
// 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영.
setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '견적마감' } : e)));
setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: QuotationStatus.CLOSED } : e)));
stopQuotationMutation.mutate(
{ qtId: id },
{
onSuccess: () => {
invalidateQuotations();
showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info');
// 열려있는 상세 Sheet 도 즉시 동기화(단건 견적 상태 + 세션 상태 재조회).
queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(id) });
showToast(`[${name}] 견적이 마감되었습니다.`, 'info');
},
onError: () => {
invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백
showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
showToast('견적 마감에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
},
},
);
@ -162,7 +168,7 @@ export function useQuotations() {
const payload: ReqCreateQuotation = {
qt_setting_id: input.settingId,
name: input.title,
type: input.type === 'RE_ESTIMATE' ? 2 : 1,
type: input.type,
end_time: new Date(input.dueDate).toISOString(),
item_ids: [input.productId],
supplier_ids: input.partnerIds,
@ -194,8 +200,9 @@ export function useQuotations() {
partners,
cards,
quotations,
total,
quotationSettings,
stopNegotiation,
closeQuotation,
addSetting,
deleteSetting,
createQuotation,

View File

@ -4,39 +4,48 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
import type { QuotationData } from '@/api/generated/model/quotationData';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import type {
Product,
Partner,
QuotationSetting,
Estimate,
ChatSession,
NegotiationCard,
} from '@/types';
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
import { toMinPrice } from '@/features/products/types';
import type { Product, Partner, NegotiationCard } from '@/types';
export type { Product, Partner, QuotationSetting, Estimate, ChatSession, NegotiationCard } from '@/types';
export type { Product, Partner, NegotiationCard } from '@/types';
export type Estimate = Partial<QuotationData> & {
id?: string;
dueDate?: string;
title?: string;
productId?: string;
productName?: string;
partnerIds?: string[];
participationCount?: number;
winnerPartnerId?: string | null;
finalPrice?: number;
isEqualPrice?: boolean;
usedCardIds?: string[];
settingApplied?: boolean | string;
};
export interface QuotationSetting {
qt_setting_id: string;
user_id: string;
target_margin: string;
anchoring_value: string;
card_use_count: string;
created_at: string;
updated_at: string;
deleted: boolean;
}
// ── 서버 응답 → UI 모델 매퍼 ─────────────────────────────────────────────
// customFetch 가 응답 본문을 그대로 반환하므로 query.data 가 곧 봉투(ResXxxList) — 추가 언랩 불필요.
export function unwrap<T>(env: unknown): T | undefined {
return (env as T | undefined) ?? undefined;
}
export function mapItem(it: ItemData): Product {
return { ...it, id: it.item_id, minPrice: Math.round((it.price || 0) * 0.83), status: 'ACTIVE' } as Product;
return { ...it, id: it.item_id, minPrice: toMinPrice(it.price), status: 'ACTIVE' } as Product;
}
export function mapSupplier(sp: SupplierData): Partner {
return {
supplier_id: sp.supplier_id,
company_id: sp.company_id,
name: sp.name,
code: sp.code ?? null,
manager_name: sp.manager_name ?? null,
manager_email: sp.manager_email ?? null,
priority: sp.priority ?? null,
created_at: sp.created_at ?? undefined,
updated_at: sp.updated_at ?? undefined,
...sp,
id: sp.supplier_id,
managerName: sp.manager_name || '',
managerEmail: sp.manager_email || '',
@ -66,8 +75,8 @@ export function mapQuotation(q: QuotationData): Estimate {
...(q as unknown as Partial<Estimate>),
id: q.qt_id,
title: q.name,
type: normalizeQuotationType(q.type),
status: normalizeQuotationStatus(q.status) || String(q.status ?? ''),
type: q.type,
status: q.status,
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
@ -89,47 +98,32 @@ function formatDueDate(end?: string | null): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ── 견적상태 정규화(영문 enum / 한글 DDL 혼용 대응) ──────────────────────
export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
export const QUOTATION_STATUS_FILTERS: QtStatusKey[] = [
'견적생성',
'견적진행중',
'견적마감',
'협상보류',
];
export const QUOTATION_STATUS_LABEL: Record<QuotationStatus, QtStatusKey> = {
[QuotationStatus.CREATED]: '견적생성',
[QuotationStatus.ACTIVE]: '견적진행중',
[QuotationStatus.CLOSED]: '견적마감',
[QuotationStatus.ON_HOLD]: '협상보류',
};
export const quotationStatusLabel = (s?: number | null): string =>
s != null ? QUOTATION_STATUS_LABEL[s as QuotationStatus] ?? String(s) : '';
// QuotationStatus 코드(SMALLINT) ↔ 한글 상태키. 영문 enum/한글 DDL/숫자 코드 혼용을 모두 흡수.
export function normalizeQuotationStatus(status?: string | number | null): QtStatusKey | '' {
switch (status) {
case 1:
case 'PROCESSING':
case '견적생성':
return '견적생성';
case 2:
case 'ACTIVE':
case '견적진행중':
return '견적진행중';
case 3:
case 'COMPLETED':
case '견적마감':
return '견적마감';
case 4:
case 'STOPPED':
case '협상보류':
return '협상보류';
default:
return '';
}
}
export const QUOTATION_STATUS_OPTIONS = Object.values(QuotationStatus).map((value) => ({
value,
label: QUOTATION_STATUS_LABEL[value],
}));
// QuotationType 코드(1=재협상, 2=재견적) ↔ UI 유형값. 이미 문자열이면 그대로 통과.
export function normalizeQuotationType(type?: string | number | null): 'RE_NEGOTIATION' | 'RE_ESTIMATE' {
if (type === 1 || type === 'RE_NEGOTIATION') return 'RE_NEGOTIATION';
if (type === 2 || type === 'RE_ESTIMATE') return 'RE_ESTIMATE';
return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE';
}
export const QUOTATION_TYPE_LABEL: Record<QuotationType, string> = {
[QuotationType.RENEGO]: '재협상',
[QuotationType.REQUOTE]: '재견적',
};
export const quotationTypeLabel = (t?: number | null): string =>
t != null ? QUOTATION_TYPE_LABEL[t as QuotationType] ?? String(t) : '';
export const QUOTATION_TYPE_OPTIONS = [QuotationType.REQUOTE, QuotationType.RENEGO].map((value) => ({
value,
label: QUOTATION_TYPE_LABEL[value],
}));
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
@ -150,7 +144,7 @@ export type SessionView = {
supplier_name: string;
item_id: string;
item_name: string;
status: string;
status: number;
target_price: number;
bid_price: number | null;
bid_at: string;
@ -163,115 +157,38 @@ export type SessionView = {
export type QuotationCardView = {
session_card_id: string;
card_id: string | null; // 실제 카드 id(협상=nego_card_id, 와일드=wild_card_id) — /cards?edit= 링크용
card_id: string | null; // 실제 카드 id(협상=nego_card_id, 와일드=wild_card_id) — /cards?detail= 링크용
card_name: string;
type: string;
};
// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출.
export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryView {
if (est.id === 'est-1') {
return {
bid_summary_id: 'bid-summary-111-uuid',
status: '입찰진행중 (ACTIVE)',
qt_iteration: 2,
has_preferred: true,
preferred_sp_id: 'part-1',
preferred_sp_name: '(주)우성테크놀로지',
equal_data: '-',
};
}
if (est.id === 'est-2') {
return {
bid_summary_id: 'bid-summary-222-uuid',
status: '입찰종료 (COMPLETED)',
qt_iteration: 1,
has_preferred: true,
preferred_sp_id: 'part-2',
preferred_sp_name: '대현정밀공업 (주)',
equal_data: JSON.stringify({ 'part-2': 730000, 'part-3': 730000 }),
};
}
if (est.id === 'est-3') {
return {
bid_summary_id: 'bid-summary-333-uuid',
status: '입찰활성화 (ACTIVE)',
qt_iteration: 1,
has_preferred: false,
preferred_sp_id: null,
preferred_sp_name: '-',
equal_data: '-',
};
}
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
const winnerId = q.preferred_sp_id ?? null;
return {
bid_summary_id: `bid-summary-${est.id}`,
status: est.status === 'COMPLETED' ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
qt_iteration: 1,
has_preferred: !!est.winnerPartnerId,
preferred_sp_id: est.winnerPartnerId || null,
preferred_sp_name: est.winnerPartnerId
? partners.find((p) => p.id === est.winnerPartnerId)?.name || '-'
: '-',
equal_data: '-',
bid_summary_id: `bid-summary-${q.qt_id}`,
status: q.status === QuotationStatus.CLOSED ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
qt_iteration: q.iteration ?? 1,
has_preferred: !!winnerId,
preferred_sp_id: winnerId,
preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'),
equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_data : '-',
};
}
// 협력사별 1개의 세션(sessions). 채팅 세션 + 상품/협력사 정보를 합성.
export function buildSessions(
est: Estimate,
sessions: ChatSession[],
partners: Partner[],
products: Product[],
): SessionView[] {
return sessions.map((sess) => {
const supplierObj = partners.find((p) => p.id === sess.id);
const matchedProduct = products.find((p) => p.id === est.productId);
let reject_reason: string | null = null;
let reject_price: number | null = null;
let reject_delivery_type: string | null = null;
if (sess.id === 'part-2' && est.id === 'est-1') {
reject_reason = '셀 공급 마진 미달로 단가 수용 한계 봉착';
reject_price = 1480000;
reject_delivery_type = '특수 보온 수송 차량 필요';
}
return {
session_id: `sess-${est.id}-${sess.id}`,
qt_id: est.id ?? '',
supplier_id: sess.id,
supplier_name: supplierObj?.name || sess.partnerName,
item_id: est.productId || 'prod-1',
item_name: matchedProduct?.name || '부품',
status: sess.status || '협상중',
target_price: Math.round((matchedProduct?.price || 1000000) * 0.9),
bid_price: sess.currentBid || null,
bid_at: sess.bidTime || '2026-06-11 09:00',
reject_reason,
reject_price,
reject_delivery_type,
end_time: est.end_time || est.dueDate || '미지정',
url: '',
};
});
}
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
// (정본은 서버 enum — 여기 값은 그걸 미러링한 것이며 드리프트 시 서버 기준으로 맞춘다.)
export const SESSION_STATUS_LABEL: Record<number, string> = {
1: '협상생성',
2: '협상중',
3: '협상완료',
4: '미참여',
5: '협상거부',
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
[SessionStatus.CREATED]: '협상생성',
[SessionStatus.IN_PROGRESS]: '협상중',
[SessionStatus.DONE]: '협상완료',
[SessionStatus.NOT_PARTICIPATED]: '미참여',
[SessionStatus.REJECTED]: '협상거부',
};
// 코드→라벨 단일 진입점. 미정의 코드는 코드 문자열 그대로.
export const sessionStatusLabel = (code?: number | null): string =>
(code != null ? SESSION_STATUS_LABEL[code] : undefined) ?? String(code ?? '');
const DELIVERY_TYPE_LABEL: Record<number, string> = { 1: '협력사배송', 2: '지정택배배송', 3: '픽업배송' };
(code != null ? SESSION_STATUS_LABEL[code as SessionStatus] : undefined) ?? String(code ?? '');
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'.
export function fmtDateTime(s?: string | null): string {
@ -294,7 +211,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
supplier_name: supplier?.name || sd.supplier_id,
item_id: sd.item_id,
item_name: product?.name || '부품',
status: SESSION_STATUS_LABEL[sd.status] || String(sd.status),
status: sd.status,
target_price: sd.target_price ?? 0,
bid_price: sd.bid_price ?? null,
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
@ -314,7 +231,7 @@ export function mapServerCardView(c: QuotationCardData): QuotationCardView {
session_card_id: c.session_card_id,
card_id: c.nego_card_id ?? c.wild_card_id ?? null,
card_name: c.name || '-',
type: c.type === 2 ? '와일드 카드' : '협상 카드',
type: c.type === CardType.WILD ? '와일드 카드' : '협상 카드',
};
}

View File

@ -0,0 +1,16 @@
import { DeliveryType, UserRole } from '@/api/generated/model';
export const DELIVERY_TYPE_LABEL: Record<DeliveryType, string> = {
[DeliveryType.PARTNER]: '협력사배송',
[DeliveryType.COURIER]: '지정택배배송',
[DeliveryType.PICKUP]: '픽업배송',
};
export const DELIVERY_TYPE_OPTIONS = Object.values(DeliveryType).map((value) => ({
value,
label: DELIVERY_TYPE_LABEL[value],
}));
export const USER_ROLE_LABEL: Record<UserRole, string> = {
[UserRole.USER]: '일반',
[UserRole.MANAGER]: '관리자',
};

View File

@ -1,35 +0,0 @@
import { useSearchParams } from 'react-router';
// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처.
// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다.
// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거).
//
// const overlay = useOverlayParams(['edit', 'new', 'modal']);
// overlay.get('edit') // ?edit=<id> 의 값(없으면 null) — 값 있는 오버레이
// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이
// overlay.open('edit', id) // ?edit=<id> (다른 오버레이 키는 지움)
// overlay.open('new') // ?new (값 생략 시 '1')
// overlay.close() // 그룹 내 모든 오버레이 키 제거
export function useOverlayParams<K extends string>(keys: readonly K[]) {
const [searchParams, setSearchParams] = useSearchParams();
const get = (key: K) => searchParams.get(key);
const has = (key: K) => searchParams.has(key);
const open = (key: K, value = '1') =>
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
keys.forEach((k) => next.delete(k));
next.set(key, value);
return next;
});
const close = () =>
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
keys.forEach((k) => next.delete(k));
return next;
});
return { get, has, open, close };
}

View File

@ -0,0 +1,50 @@
import { useLocation, useNavigate, useSearchParams } from 'react-router';
// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처.
// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다.
// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거).
//
// const overlay = useOverlayRouter(['detail', 'new', 'modal']);
// overlay.get('detail') // ?detail=<id> 의 값(없으면 null) — 값 있는 오버레이
// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이
// overlay.open('detail', id) // ?detail=<id> (다른 오버레이 키는 지움) — 히스토리 push
// overlay.open('new') // ?new (값 생략 시 '1')
// overlay.close() // 그룹 내 모든 오버레이 키 제거
const OVERLAY_PUSHED = '__overlayPushed';
export function useOverlayRouter<K extends string>(keys: readonly K[]) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const get = (key: K) => searchParams.get(key);
const has = (key: K) => searchParams.has(key);
// 현재 쿼리에서 그룹 키를 모두 지운 뒤 mutate 를 적용해 search 문자열을 만든다.
const buildSearch = (mutate: (params: URLSearchParams) => void) => {
const next = new URLSearchParams(searchParams);
keys.forEach((k) => next.delete(k));
mutate(next);
const s = next.toString();
return s ? `?${s}` : '';
};
const open = (key: K, value = '1') =>
navigate(
{ pathname: location.pathname, search: buildSearch((p) => p.set(key, value)) },
{ state: { ...(location.state ?? {}), [OVERLAY_PUSHED]: true } },
);
const close = () => {
if (location.state?.[OVERLAY_PUSHED]) {
navigate(-1);
return;
}
navigate(
{ pathname: location.pathname, search: buildSearch(() => {}) },
{ replace: true },
);
};
return { get, has, open, close };
}

View File

@ -1,13 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
// 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처.
// 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로
// 쿼리 파라미터를 만들어 수행한다 — 이 훅은 패칭을 하지 않고 상태만 관리한다.
//
// - search: 입력 즉시 반영(controlled) + debouncedSearch(쿼리용, 기본 300ms)로 분리해
// 키 입력마다 서버를 때리지 않는다.
// - filters: 임의 키-값(category/priority 등). 'ALL' 같은 "전체" 값의 의미는
// 호출부가 파라미터를 만들 때 결정한다(여기선 단순 보관).
// - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지).
export type ServerListControls = {
page: number;
@ -15,6 +9,7 @@ export type ServerListControls = {
pageSize: number;
search: string; // input value (controlled)
setSearch: (v: string) => void;
submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사)
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
filters: Record<string, string>;
setFilter: (key: string, value: string) => void;
@ -25,25 +20,39 @@ export function useServerList(opts?: {
pageSize?: number;
initialFilters?: Record<string, string>;
debounceMs?: number;
minSearchLength?: number;
}): ServerListControls {
const pageSize = opts?.pageSize ?? 10;
const debounceMs = opts?.debounceMs ?? 300;
const debounceMs = opts?.debounceMs ?? 500;
const minSearchLength = opts?.minSearchLength ?? 2;
const [page, setPage] = useState(1);
const [search, setSearchInput] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filters, setFilters] = useState<Record<string, string>>(() => opts?.initialFilters ?? {});
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// 입력 디바운스 → 쿼리용 검색어
// 입력 디바운스 → 쿼리용 검색어.
// 최소 길이 미만은 빈 검색(전체)으로 둬서 1글자 스캔 요청이 서버로 나가지 않게 막는다.
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), debounceMs);
return () => clearTimeout(t);
}, [search, debounceMs]);
timerRef.current = setTimeout(() => {
const q = search.trim();
setDebouncedSearch(q.length >= minSearchLength ? q : '');
}, debounceMs);
return () => clearTimeout(timerRef.current);
}, [search, debounceMs, minSearchLength]);
const setSearch = (v: string) => {
setSearchInput(v);
setPage(1);
};
// 엔터: 대기 중인 디바운스 타이머를 버리고 최소길이 무시하고 즉시 1회 발사(의도적 검색).
const submitSearch = () => {
clearTimeout(timerRef.current);
setDebouncedSearch(search.trim());
setPage(1);
};
const setFilter = (key: string, value: string) => {
setFilters((f) => ({ ...f, [key]: value }));
setPage(1);
@ -51,5 +60,5 @@ export function useServerList(opts?: {
const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize));
return { page, setPage, pageSize, search, setSearch, debouncedSearch, filters, setFilter, totalPages };
return { page, setPage, pageSize, search, setSearch, submitSearch, debouncedSearch, filters, setFilter, totalPages };
}

View File

@ -1,33 +1,43 @@
import { Plus, BookOpen } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { SearchInput } from '@/components/layout/PageToolbar';
import { TablePagination } from '@/components/ui/table-pagination';
import { Typography } from '@/components/ui/typography';
import { useClientPagination } from '@/lib/useClientPagination';
import { useServerList } from '@/lib/useServerList';
import { useCards } from '@/features/cards/hooks/useCards';
import { useCardFilters } from '@/features/cards/hooks/useCardFilters';
import { useGetCard } from '@/api/generated/card/card';
import { CardTable } from '@/features/cards/components/CardTable';
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
import type { CardTab, NegotiationCard } from '@/features/cards/types';
import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
export default function CardsPage() {
const { cards, createCard, updateCard, deleteCard } = useCards();
const { search, setSearch, activeTab, setActiveTab, filtered, counts } = useCardFilters(cards);
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
// 검색/탭/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { tab: 'ALL' } });
const activeTab = list.filters.tab as CardTab;
const params: ListCardsParams = {
search: list.debouncedSearch || undefined,
is_wildcard: activeTab === 'ALL' ? undefined : activeTab === 'WILD',
page: list.page,
size: list.pageSize,
};
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard } = useCards(params);
const totalPages = list.totalPages(total);
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayParams(['new', 'edit']);
const editId = overlay.get('edit');
const editing = editId ? cards.find((c) => c.id === editId) ?? null : null;
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
const overlay = useOverlayRouter(['new', 'detail']);
const editId = overlay.get('detail');
const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } });
const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (card: NegotiationCard) => overlay.open('edit', card.id);
const openEdit = (card: NegotiationCard) => overlay.open('detail', card.id);
const handleDeleteCard = async (id: string, cardName: string) => {
if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
@ -41,9 +51,9 @@ export default function CardsPage() {
};
const tabs: { id: CardTab; label: string; count: number }[] = [
{ id: 'ALL', label: '전체', count: counts.all },
{ id: 'CARD', label: '협상카드', count: counts.card },
{ id: 'WILD', label: '와일드카드', count: counts.wild },
{ id: 'ALL', label: '전체', count: totalNego + totalWild },
{ id: 'CARD', label: '협상카드', count: totalNego },
{ id: 'WILD', label: '와일드카드', count: totalWild },
];
return (
@ -76,7 +86,7 @@ export default function CardsPage() {
<button
key={tab.id}
id={`card-tab-${tab.id.toLowerCase()}`}
onClick={() => setActiveTab(tab.id)}
onClick={() => list.setFilter('tab', tab.id)}
className={`py-2 px-6 font-bold text-xs tracking-tight border-b-2 transition-all cursor-pointer ${
activeTab === tab.id
? 'border-primary text-primary'
@ -91,21 +101,22 @@ export default function CardsPage() {
{/* Filtering Search Bar */}
<SearchInput
id="card-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="전체 카드이름, 카드번호, 코드 및 핵심멘트 검색..."
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="전체 카드이름, 카드번호, 코드 검색..."
/>
<CardTable
data={pageItems}
data={cards}
onEdit={openEdit}
footer={
<TablePagination
page={page}
page={list.page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={setPage}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
label="전체 카드"
unit="개"
/>

View File

@ -1,5 +1,5 @@
import { Plus, Upload } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
@ -30,16 +30,16 @@ export default function PartnersPage() {
const totalPages = list.totalPages(total);
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayParams(['new', 'edit', 'modal']);
const editId = overlay.get('edit');
// ?detail=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'excel' | null
const editing = editId ? allPartners.find((p) => p.supplier_id === editId) ?? null : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (part: Partner) => overlay.open('edit', part.supplier_id);
const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id);
const handleDeletePartner = async (id: string, partnerName: string) => {
if (await confirm({ title: '협력사 삭제', description: `[${partnerName}] 파트너사를 협력사 목록에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
@ -73,6 +73,7 @@ export default function PartnersPage() {
id="partner-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
/>

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Plus, Upload, TrendingDown } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
@ -45,17 +45,16 @@ export default function ProductsPage() {
// 행 선택(테이블 체크박스 + 최저가 모달 대상)
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 오버레이(폼/최저가/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
const overlay = useOverlayParams(['new', 'edit', 'modal']);
const editId = overlay.get('edit');
// 딥링크·뒤로가기·새로고침 지원
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'price' | 'excel' | null
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const openCreate = () => overlay.open('new');
const openEdit = (prod: Product) => overlay.open('edit', prod.item_id);
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
const handleDeleteProduct = async (id: string, prodName: string) => {
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
@ -99,6 +98,7 @@ export default function ProductsPage() {
id="product-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="상품명 또는 상품 코드로 통합 검색..."
/>

View File

@ -1,43 +1,56 @@
import { Settings, Plus } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { TablePagination } from '@/components/ui/table-pagination';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClientPagination } from '@/lib/useClientPagination';
import { useServerList } from '@/lib/useServerList';
import { useQuotations } from '@/features/quotations/hooks/useQuotations';
import { useQuotationFilters } from '@/features/quotations/hooks/useQuotationFilters';
import { useGetQuotation } from '@/api/generated/quotation/quotation';
import { QuotationTable } from '@/features/quotations/components/QuotationTable';
import { QuotationDetailDrawer } from '@/features/quotations/components/QuotationDetailDrawer';
import { CreateQuotationWizard } from '@/features/quotations/components/CreateQuotationWizard';
import { QuotationDetailSheet } from '@/features/quotations/components/QuotationDetailSheet';
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
import { QUOTATION_STATUS_FILTERS } from '@/features/quotations/types';
import { QUOTATION_STATUS_OPTIONS, QUOTATION_TYPE_OPTIONS } from '@/features/quotations/types';
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
export default function QuotationPage() {
// 검색/상태·유형 필터/페이지 상태 → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } });
const statusFilter = list.filters.status;
const typeFilter = list.filters.type;
const statusOptions = QUOTATION_STATUS_OPTIONS;
const typeOptions = QUOTATION_TYPE_OPTIONS;
const params: ListQuotationsParams = {
search: list.debouncedSearch || undefined,
status: statusFilter !== 'ALL' ? statusFilter : undefined,
type: typeFilter !== 'ALL' ? typeFilter : undefined,
page: list.page,
size: list.pageSize,
};
const {
products,
partners,
cards,
quotations,
total,
quotationSettings,
stopNegotiation,
closeQuotation,
addSetting,
deleteSetting,
createQuotation,
} = useQuotations();
} = useQuotations(params);
const totalPages = list.totalPages(total);
const { search, setSearch, statusFilter, setStatusFilter, typeFilter, setTypeFilter, filtered } =
useQuotationFilters(quotations);
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
// 오버레이(상세/생성/세팅)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?detail=<id> 로 직접 접근하면 데이터 로드 후 상세가 자동으로 열린다.
const overlay = useOverlayParams(['detail', 'create', 'settings']);
const overlay = useOverlayRouter(['detail', 'create', 'settings']);
const detailId = overlay.get('detail');
const isCreateOpen = overlay.has('create');
const isSettingsOpen = overlay.has('settings');
const activeQuotation = quotations.find((e) => e.id === detailId) ?? null;
// 상세 요약은 리스트에서 find 하지 않고 단건 API 로 받아온다(딥링크 시 리스트 의존 제거).
const detailQuery = useGetQuotation(detailId ?? '', { query: { enabled: !!detailId } });
const activeQuotation = detailQuery.data?.quotation ?? null;
return (
<PageContainer>
@ -56,60 +69,72 @@ export default function QuotationPage() {
<button
id="quotation-create-btn"
onClick={() => overlay.open('create')}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors animate-pulse"
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors"
>
<Plus size={15} />
<span> </span>
<span> </span>
</button>
</>
}
>
<SearchInput
id="quotation-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="견적명 또는 견적 번호로 실시간 서치..."
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
placeholder="견적명 또는 견적 번호로 검색..."
/>
<div className="grid grid-cols-2 gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as string)}>
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
<SelectTrigger id="quotation-status-filter" className="font-bold">
<SelectValue />
<SelectValue>
{(value) =>
value === 'ALL'
? '전체 견적상태'
: statusOptions.find((o) => String(o.value) === value)?.label ?? ''
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL"> </SelectItem>
{QUOTATION_STATUS_FILTERS.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
{statusOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as string)}>
<Select value={typeFilter} onValueChange={(v) => list.setFilter('type', v as string)}>
<SelectTrigger id="quotation-type-filter">
<SelectValue>
{(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')}
{(value) =>
value === 'ALL'
? '전체 유형'
: typeOptions.find((o) => String(o.value) === value)?.label ?? ''
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL"> </SelectItem>
<SelectItem value="RE_NEGOTIATION"></SelectItem>
<SelectItem value="RE_ESTIMATE"></SelectItem>
{typeOptions.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</PageToolbar>
<QuotationTable
data={pageItems}
data={quotations}
products={products}
onOpenDetail={(id) => overlay.open('detail', id)}
footer={
<TablePagination
page={page}
page={list.page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={setPage}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
label="전체 견적"
unit="건"
/>
@ -117,19 +142,16 @@ export default function QuotationPage() {
/>
{activeQuotation && (
<QuotationDetailDrawer
key={activeQuotation.id}
estimate={activeQuotation}
products={products}
partners={partners}
quotationSettings={quotationSettings}
onStop={stopNegotiation}
<QuotationDetailSheet
key={activeQuotation.qt_id}
quotation={activeQuotation}
onCloseQuotation={closeQuotation}
onClose={overlay.close}
/>
)}
{isCreateOpen && (
<CreateQuotationWizard
<QuotationCreateModal
open
products={products}
partners={partners}

View File

@ -1,306 +1,35 @@
// ============================================================
// Negosium/NegoData ERD Type Definitions (TypeScript)
// ------------------------------------------------------------
// 기준: postgres-init/01-schema.sql (negosium_db, 도메인별 schema) + 현행 백엔드 API 계약.
// 표기 규칙:
// - 코드값(status/role/type/quantity_unit/delivery_type 등)은 DB에서 SMALLINT 정수코드지만,
// 현행 API(openapi.json) 가 문자열로 직렬화하므로 여기서는 string 으로 둔다(주석에 DB 타입 명시).
// - DB 에 대응 테이블/컬럼이 없는 항목은 "⚠ DB 없음" 으로 표시한다.
// ============================================================
// 2개 이상 feature가 공유하는 타입만 둔다. 단일 feature 전용은 그 feature/types.ts 로.
import type { ItemData } from '@/api/generated/model/itemData';
import type { SupplierData } from '@/api/generated/model/supplierData';
// Base abstract structure properties
interface BaseEntity {
created_at: string;
updated_at: string;
deleted: boolean;
}
// 1. Companies (고객사) — company.companies
export interface Company extends BaseEntity {
company_id: string; // uuid (Primary Key)
name: string; // 회사명
business_number: string | null; // 사업자등록번호
code: number | null; // 회사코드 (내부 인덱스용)
representative_name: string | null; // 대표자 명
email: string | null; // 대표 이메일
contact_number: string | null; // 대표 연락처
website_url: string | null; // 홈페이지 URL
industry: string | null; // 업종 (DB: SMALLINT 코드)
status: string; // 상태 (DB: SMALLINT NOT NULL, 1=active 2=inactive)
}
// 2. Users (유저) — company.users
export interface User extends BaseEntity {
user_id: string; // uuid (Primary Key)
company_id: string; // 회사 아이디 (company.companies.company_id)
id: string; // 로그인시, 입력 아이디
password: string; // 비밀번호 (해시)
name: string | null; // 이름
email: string | null; // 이메일
contact_number: string | null; // 전화번호
last_accessed_at: string; // 마지막 접속 시간
status: string; // 상태 (DB: SMALLINT, 1=active 2=inactive)
role: string; // 권한 (DB: SMALLINT, 1=user 2=manager)
}
// 3. User Tokens (유저 토큰) — company.user_tokens
export interface UserToken extends BaseEntity {
user_tokens_id: string; // uuid (Primary Key) — DB 컬럼명 user_tokens_id
user_id: string; // 유저 아이디 (company.users.user_id)
type: string; // 토큰 타입 (DB: SMALLINT 코드)
token: any; // 토큰 값 (JSONB)
issued_at: string;
expired_at: string;
}
// 4. Suppliers (협력사) - 기존 Partner 대응 — partner.suppliers
export interface Supplier extends BaseEntity {
supplier_id: string; // uuid (Primary Key)
company_id: string; // 회사 아이디 (company.companies.company_id)
user_id: string; // 등록 유저 (company.users.user_id) — DB NOT NULL
name: string; // 협력사명
code: string | null; // 협력사코드
manager_name: string | null; // 담당자명
manager_email: string | null; // 담당자 이메일
manager_contact_number: string | null; // 담당자 연락처 (DB 철자 정상 — 기존 typo 가정은 오류였음)
priority: string | null; // 우선순위 (DB: VARCHAR, 고객사별 문자열 유지)
}
// 5. Items (상품 정보) - 기존 Product 대응 — partner.items
export interface Item extends BaseEntity {
item_id: string; // uuid (Primary Key)
company_id: string; // 회사 아이디 (company.companies.company_id)
user_id: string; // 등록 유저 (company.users.user_id) — DB NOT NULL
name: string; // 상품명
code: string | null; // 상품코드
price: number | null; // 상품 단가 (DB: BIGINT)
category: string | null; // 상품 카테고리 (free text)
category_type: number; // 카테고리 조회용 정수 (DB: INTEGER NOT NULL DEFAULT 1, 자동증가 아님)
image_url: string | null; // 상품 이미지 URL
model_name: string | null; // 상품 모델명
spec: string | null; // 상품 규격
moq: string | null; // 상품 MOQ
lead_time: number | null; // 상품 리드타임 (DB: SMALLINT)
manufacturer: string | null; // 상품 제조사
made_in: string | null; // 상품 제조 국가
quantity_unit: string | null; // 상품 취급 단위 (DB: SMALLINT 코드)
delivery_type: string | null; // 상품 배송형태 (DB: SMALLINT 코드)
vat_yn: boolean | null; // VAT 포함 여부
delivery_fee_yn: boolean | null; // 배송비 포함 여부
internet_lowest_price_yn: boolean; // 인터넷 최저가 보조 플래그
}
// 6. Item Internet Lowest Prices — partner.item_internet_lowest_prices
// (※ 기존 url/crawled_at 필드는 DB 에 없어 제거. 실제 DB 컬럼에 맞춤.)
export interface ItemInternetLowestPrice extends BaseEntity {
lp_id: string; // uuid (Primary Key)
item_id: string; // 상품 아이디 (partner.items.item_id)
lp_price: number | null; // 크롤링한 최저가 (DB: BIGINT)
website: number; // 크롤링 대상 사이트 (DB: SMALLINT 코드)
success_yn: boolean; // 크롤링 성공 여부
fail_reason: string | null; // 실패 사유
ai_model: number | null; // 사용한 AI 모델 (DB: SMALLINT 코드)
crawl_duration_ms: number | null; // 크롤링 소요 시간(ms)
crawl_end_time: string; // 크롤링 종료 시각
}
// 7. Quotation Settings (견적 세팅) — quotation.quotation_settings
// (※ DB·API 모두 user_id 보유. 전역 기본 설정은 NULL 가능.)
export interface QuotationSetting extends BaseEntity {
qt_setting_id: string; // uuid (Primary Key)
user_id: string; // 유저 아이디 (company.users.user_id). DB 는 nullable(전역 기본=NULL)이나 UI 매퍼가 '' 로 정규화
target_margin: string; // 목표 마진율 (DB: NUMERIC(8,6))
anchoring_value: string; // 앵커링 설정 값 (DB: NUMERIC(8,6))
card_use_count: string; // 협상 카드 사용 횟수 (DB: card_count INTEGER)
}
// 8. Quotations (견적) - 기존 Estimate 대응 — quotation.quotations
// (※ company_id 는 DB/API 모두에 없어 제거. 회사 스코프는 user_id 경유.)
export interface Quotation extends BaseEntity {
qt_id: string; // uuid (Primary Key)
user_id: string; // 유저 아이디 (company.users.user_id)
qt_setting_id: string; // 견적 세팅 아이디 (quotation.quotation_settings.qt_setting_id)
version_id: string; // 협상전략 버전 (card.versions.version_id) — DB NOT NULL
name: string; // 견적 명
number: string; // 견적 번호
type: string; // 견적 타입 (DB: SMALLINT, 1=renego 2=requote)
round: number; // 견적 차수 (기본 1)
status: string; // 견적 상태 (DB: SMALLINT 코드)
start_time: string; // 견적 시작 시간
end_time: string; // 견적 마감 시간
manager_name: string | null; // 견적 담당자 명
manager_email: string | null; // 견적 담당자 이메일
manager_contact_number: string | null; // 견적 담당자 연락처
memo: string | null; // 견적 안내사항
iteration: number; // 반복 횟수 (DB NOT NULL DEFAULT 0)
preferred_sp_yn: boolean | null; // 선호 공급사 지정 여부
preferred_sp_id: string | null; // 선호 공급사 (partner.suppliers.supplier_id)
preferred_sp_name: string | null; // 선호 공급사명(스냅샷)
equal_bid_yn: boolean | null; // 동일가 입찰 발생 여부
equal_bid_data: any | null; // 동일가 입찰 상세 (JSONB)
}
// 9. Nego Cards (협상카드) — card.nego_cards
// (※ company_id 는 DB 에 없어 제거. 소유는 user_id(nullable) 만.)
export interface NegoCard extends BaseEntity {
nego_card_id: string; // uuid (Primary Key)
user_id: string | null; // 유저 아이디 (o2o 기본 카드는 NULL)
name: string | null; // 카드 이름
number: string | null; // 카드 번호 (식별번호)
script: string | null; // 스크립트
edit_script: any | null; // 편집 스크립트 (JSONB)
}
// 10. Wild Cards (와일드 카드) — card.wild_cards
// (※ company_id 는 DB 에 없어 제거.)
export interface WildCard extends BaseEntity {
wild_card_id: string; // uuid (Primary Key)
user_id: string | null; // 유저 아이디 (o2o 기본 카드는 NULL)
name: string | null; // 카드 이름
number: string | null; // 카드 번호 (식별번호)
script: string | null; // 스크립트
edit_script: any | null; // 편집 스크립트 (JSONB)
condition: string | null; // 카드 사용 조건
available: boolean; // 협상 적용 가능 여부
memo: string | null; // 메모
}
// 11. Sessions (세션) - 기존 ChatSession 대응 — negotiation.sessions
// (※ user_id, bid_summary_id 는 DB sessions 에 없어 제거. bid_summary 개념은 quotations 로 흡수됨.)
export interface Session extends BaseEntity {
session_id: string; // uuid (Primary Key)
quotation_id: string; // 소속 견적 (quotation.quotations.qt_id) — DB 컬럼명 quotation_id
item_id: string; // 상품 아이디 (partner.items.item_id)
supplier_id: string; // 협력사 아이디 (partner.suppliers.supplier_id)
qt_number: string; // 견적번호(스냅샷)
qt_round: number; // 견적 차수(스냅샷)
qt_type: string; // 견적 타입(스냅샷, DB: SMALLINT)
target_price: number; // 목표 가격 (DB: BIGINT)
status: string; // 협상 상태 (DB: SMALLINT 코드)
bid_price: number | null; // 최종 입찰 가격
bid_at: string | null; // 최종 입찰 시간
end_time: string; // 협상 종료 시간
reject_reason: string | null; // 협상 거부 사유
reject_price: number | null; // 협상 거부 가격
reject_delivery_type: string | null; // 협상 거부 시 배송 형태 (DB: SMALLINT 코드)
}
// 12. Bid Summary (견적 입찰 정보)
// ⚠ DB 없음: 별도 테이블이 없고 quotation.quotations 의 preferred_sp_* / equal_bid_* 컬럼으로 흡수됨.
// UI(QuotationDetailDrawer) 의 입찰 요약 표시용 파생 모델로만 존재.
export interface BidSummary {
bid_summary_id: string; // (파생) UI 식별자
qt_id: string; // 견적 아이디 (quotation.quotations.qt_id)
qt_type: string; // 견적 타입 (재협상/재견적)
qt_iteration: number; // 견적 반복횟수 (← quotations.iteration)
status: string; // 입찰 상태 (← quotations.status)
has_preferred: boolean; // ← quotations.preferred_sp_yn
preferred_sp_id: string | null; // ← quotations.preferred_sp_id
preferred_sp_name: string | null; // ← quotations.preferred_sp_name
equal_data: any | null; // ← quotations.equal_bid_data (JSONB)
}
// 13. Results (세션 결과) — negotiation.results (스키마 미확정 스텁)
export interface Result extends BaseEntity {
result_id: string; // uuid (Primary Key) — DB 컬럼명 result_id
}
// 14. Quotation Cards (견적↔카드 연결)
// API(QuotationCardResponse) 로는 노출되나, 33KB DB 에는 quotation_cards 테이블이 없고
// card.version_nego_cards / card.version_wild_cards (버전↔카드) 매핑으로 실현된다
// (quotation.version_id → version_*_cards → nego/wild_cards).
export interface QuotationCard {
session_card_id: string; // uuid (Primary Key, API 기준)
wild_card_id: string | null; // 와일드 카드 아이디 (card.wild_cards.wild_card_id)
nego_card_id: string | null; // 협상카드 아이디 (card.nego_cards.nego_card_id)
qt_id: string | null; // 견적 아이디 (quotation.quotations.qt_id)
type: string | null; // 카드 타입 (DB: SMALLINT, 1=nego 2=wild)
}
// 15. Chats (채팅 내역) — negotiation.chats
// (※ API(ChatMessageResponse) 는 순번 컬럼을 index 로, DB 는 seq 로 부른다.)
export interface Chat extends BaseEntity {
chat_id: string; // uuid (Primary Key)
session_id: string; // 소속 세션 (negotiation.sessions.session_id)
card_id: string | null; // 사용된 카드 (card.nego_cards/wild_cards, 다형성)
seq: number; // 세션 내 메시지 순번 (API: index)
sender: string; // 발신자 구분 (DB: SMALLINT 코드)
target_price: number; // 제시 목표가 (DB: BIGINT)
card_used_yn: boolean | null; // 카드 사용 여부
indicator_value: number | null; // 지표값 (DB: NUMERIC(8,6))
card_type: string | null; // 카드 유형 (DB: SMALLINT, 1=nego 2=wild)
}
// 16. CopyOfChat (채팅 임시/백업)
// ⚠ DB 없음: 대응 테이블 없음 (프론트 임시 보관용).
export interface CopyOfChat extends BaseEntity {
id: string;
}
// ============================================================
// React UI Compatibility Helper Types
// ============================================================
export type Product = Partial<Item> & {
id?: string; // item_id back-compatibility map
minPrice?: number; // UI minimum reserve limit
status?: string; // UI lifecycle state
export type Product = ItemData & {
deleted?: boolean;
id?: string;
minPrice?: number;
status?: string;
};
export type Partner = Partial<Supplier> & {
id?: string; // supplier_id back-compatibility map
managerName?: string; // manager_name
managerEmail?: string; // manager_email
managerPhone?: string; // manager_contact_number
manager_contact_number?: string; // DB 컬럼 직접 매핑(철자 정상)
rank?: 'A' | 'B' | 'C' | 'S'; // computed from priority
status?: string; // mapped to deleted
memo?: string; // back-compatible details
export type Partner = SupplierData & {
deleted?: boolean;
id?: string;
managerName?: string;
managerEmail?: string;
managerPhone?: string;
rank?: 'A' | 'B' | 'C' | 'S';
status?: string;
memo?: string;
};
export type Estimate = Partial<Quotation> & {
id?: string; // qt_id back-compatibility map
dueDate?: string; // end_time
title?: string; // mapped to name in UI
productId?: string; // mapped to association
productName?: string; // 서버 목록 조인 상품명(products 목록에 없을 때 폴백)
partnerIds?: string[]; // mapped B2B suppliers
participationCount?: number;
winnerPartnerId?: string | null;
finalPrice?: number;
isEqualPrice?: boolean;
usedCardIds?: string[];
settingApplied?: boolean | string;
};
// UI Chat Message definition (corresponds to in-memory/rendered chats)
export interface ChatMessage {
id: string;
sender: 'BOT' | 'PARTNER' | 'SYSTEM';
timestamp: string;
content: string;
editorScript?: any; // Slate JSON structure
}
export interface ChatSession {
id: string; // matches supplier_id (or supplier.supplier_id)
partnerName: string;
status: 'NEGOTIATING' | 'COMPLETED' | 'REJECTED' | '협상생성' | '협상중' | '협상완료' | '미참여' | '협상거부';
currentBid: number;
bidTime: string;
messages: ChatMessage[];
}
export interface NegotiationCard {
id: string; // nego_card_id or wild_card_id
isWildcard: boolean; // mapping based on source table
code: string; // number (식별번호) or custom code
title: string; // name
scriptPreview: string; // script
editorScript: any; // edit_script (JSON)
id: string;
isWildcard: boolean;
code: string;
title: string;
scriptPreview: string;
editorScript: any;
status: 'ACTIVE' | 'INACTIVE';
triggerCondition?: string; // wild_card's condition
memo?: string; // wild_card's memo
triggerCondition?: string;
memo?: string;
}
export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS';