Merge branch 'feature/negodata'
This commit is contained in:
commit
5d9eedf2f6
@ -37,3 +37,9 @@ access_key = "<JWT_ACCESS_SECRET>"
|
|||||||
refresh_key = "<JWT_REFRESH_SECRET>"
|
refresh_key = "<JWT_REFRESH_SECRET>"
|
||||||
access_expire_min = 30
|
access_expire_min = 30
|
||||||
refresh_expire_day = 7
|
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
|
||||||
|
|||||||
@ -35,6 +35,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_ENV: local
|
APP_ENV: local
|
||||||
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override)
|
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:
|
ports:
|
||||||
- "9400:9400"
|
- "9400:9400"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@ -3,6 +3,17 @@ from enum import Enum, auto
|
|||||||
from fastapi import HTTPException
|
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):
|
class ErrorType(Enum):
|
||||||
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
||||||
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
||||||
@ -84,35 +95,35 @@ class DBWRType(Enum):
|
|||||||
|
|
||||||
|
|
||||||
# 도메인 코드값
|
# 도메인 코드값
|
||||||
class UserStatus(Enum):
|
class UserStatus(CodeEnum):
|
||||||
"""users.status 코드값."""
|
"""users.status 코드값."""
|
||||||
|
|
||||||
ACTIVE = 1
|
ACTIVE = 1
|
||||||
INACTIVE = 2
|
INACTIVE = 2
|
||||||
|
|
||||||
|
|
||||||
class UserRole(Enum):
|
class UserRole(CodeEnum):
|
||||||
"""users.role 코드값."""
|
"""users.role 코드값."""
|
||||||
|
|
||||||
USER = 1
|
USER = 1
|
||||||
MANAGER = 2
|
MANAGER = 2
|
||||||
|
|
||||||
|
|
||||||
class CompanyStatus(Enum):
|
class CompanyStatus(CodeEnum):
|
||||||
"""companies.status 코드값."""
|
"""companies.status 코드값."""
|
||||||
|
|
||||||
ACTIVE = 1
|
ACTIVE = 1
|
||||||
INACTIVE = 2
|
INACTIVE = 2
|
||||||
|
|
||||||
|
|
||||||
class QuotationType(Enum):
|
class QuotationType(CodeEnum):
|
||||||
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
|
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
|
||||||
|
|
||||||
RENEGO = 1
|
RENEGO = 1
|
||||||
REQUOTE = 2
|
REQUOTE = 2
|
||||||
|
|
||||||
|
|
||||||
class QuotationStatus(Enum):
|
class QuotationStatus(CodeEnum):
|
||||||
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
|
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
|
||||||
|
|
||||||
CREATED = 1
|
CREATED = 1
|
||||||
@ -121,7 +132,7 @@ class QuotationStatus(Enum):
|
|||||||
ON_HOLD = 4
|
ON_HOLD = 4
|
||||||
|
|
||||||
|
|
||||||
class SessionStatus(Enum):
|
class SessionStatus(CodeEnum):
|
||||||
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
|
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
|
||||||
|
|
||||||
CREATED = 1
|
CREATED = 1
|
||||||
@ -131,14 +142,14 @@ class SessionStatus(Enum):
|
|||||||
REJECTED = 5
|
REJECTED = 5
|
||||||
|
|
||||||
|
|
||||||
class ChatSender(Enum):
|
class ChatSender(CodeEnum):
|
||||||
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
|
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
|
||||||
|
|
||||||
BOT = 1
|
BOT = 1
|
||||||
USER = 2
|
USER = 2
|
||||||
|
|
||||||
|
|
||||||
class DeliveryType(Enum):
|
class DeliveryType(CodeEnum):
|
||||||
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
|
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
|
||||||
|
|
||||||
PARTNER = 1 # 협력사배송
|
PARTNER = 1 # 협력사배송
|
||||||
@ -146,50 +157,15 @@ class DeliveryType(Enum):
|
|||||||
PICKUP = 3 # 픽업배송
|
PICKUP = 3 # 픽업배송
|
||||||
|
|
||||||
|
|
||||||
class CardStatus(Enum):
|
class CardStatus(CodeEnum):
|
||||||
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
|
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
|
||||||
|
|
||||||
ACTIVE = 1
|
ACTIVE = 1
|
||||||
INACTIVE = 2
|
INACTIVE = 2
|
||||||
|
|
||||||
|
|
||||||
# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드).
|
class CardType(CodeEnum):
|
||||||
ENUM_LABELS = {
|
"""negotiation.chats.card_type / quotation_cards.type 코드값. 1=nego_card, 2=wild_card."""
|
||||||
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: "대기",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다.
|
NEGO = 1
|
||||||
DOMAIN_ENUMS = {
|
WILD = 2
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,15 +2,15 @@ from abc import ABC, abstractmethod
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Tuple
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import (
|
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,
|
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.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ from common.utils.gtime import GTime
|
|||||||
class IQuotationCRUD(ABC):
|
class IQuotationCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search(
|
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]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -59,6 +59,10 @@ class IQuotationCRUD(ABC):
|
|||||||
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
|
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
@ -83,11 +87,33 @@ class IQuotationCRUD(ABC):
|
|||||||
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
|
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
|
||||||
pass
|
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):
|
class QuotationCRUD(IQuotationCRUD):
|
||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
cdb: AsyncSession,
|
cdb: AsyncSession,
|
||||||
|
search: Optional[str],
|
||||||
status: Optional[str],
|
status: Optional[str],
|
||||||
type_: Optional[str],
|
type_: Optional[str],
|
||||||
start_from: Optional[datetime],
|
start_from: Optional[datetime],
|
||||||
@ -97,10 +123,12 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
try:
|
try:
|
||||||
conditions = [quotations.deleted == False] # noqa: E712
|
conditions = [quotations.deleted == False] # noqa: E712
|
||||||
|
if search:
|
||||||
|
conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%")))
|
||||||
if status:
|
if status:
|
||||||
conditions.append(quotations.status == status)
|
conditions.append(quotations.status == int(status)) # status/type 는 SMALLINT 코드 — 문자열 쿼리값을 정수로
|
||||||
if type_:
|
if type_:
|
||||||
conditions.append(quotations.type == type_)
|
conditions.append(quotations.type == int(type_))
|
||||||
if start_from:
|
if start_from:
|
||||||
conditions.append(quotations.start_time >= start_from)
|
conditions.append(quotations.start_time >= start_from)
|
||||||
if start_to:
|
if start_to:
|
||||||
@ -308,6 +336,23 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
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:
|
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
query = update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=GTime.UTC())
|
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)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
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]:
|
async def list_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -10,3 +10,4 @@ pydantic>=2.0
|
|||||||
python-multipart
|
python-multipart
|
||||||
openpyxl
|
openpyxl
|
||||||
httpx
|
httpx
|
||||||
|
apscheduler>=3.10
|
||||||
|
|||||||
@ -9,22 +9,24 @@ from common.database.db_session_manager import DB_SESSION_MNG
|
|||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from config.server_configs import web_server_config
|
from config.server_configs import web_server_config
|
||||||
|
from scheduler import shutdown_scheduler, start_scheduler
|
||||||
import router.v1.auth.account
|
import router.v1.auth.account
|
||||||
import router.v1.item.item
|
import router.v1.item.item
|
||||||
import router.v1.supplier.supplier
|
import router.v1.supplier.supplier
|
||||||
import router.v1.card.card
|
import router.v1.card.card
|
||||||
import router.v1.quotation.quotation
|
import router.v1.quotation.quotation
|
||||||
import router.v1.quotation_setting.quotation_setting
|
import router.v1.quotation_setting.quotation_setting
|
||||||
import router.v1.enums.enums
|
|
||||||
|
|
||||||
API_SERVER_START_TIME = GTime.UTCStr()
|
API_SERVER_START_TIME = GTime.UTCStr()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# startup
|
# startup: 마감 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만)
|
||||||
|
start_scheduler()
|
||||||
yield
|
yield
|
||||||
# shutdown: DB 엔진 커넥션 풀 정리
|
# shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리
|
||||||
|
shutdown_scheduler()
|
||||||
await DB_SESSION_MNG.dispose_all()
|
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.card.card.router)
|
||||||
app.include_router(router.v1.quotation.quotation.router)
|
app.include_router(router.v1.quotation.quotation.router)
|
||||||
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
||||||
app.include_router(router.v1.enums.enums.router)
|
|
||||||
|
|||||||
@ -52,6 +52,5 @@ class Res_Me(Res_WebPacketProtocol):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
contact_number: Optional[str] = None
|
contact_number: Optional[str] = None
|
||||||
role: int = UserRole.USER.value
|
role: UserRole = UserRole.USER
|
||||||
role_label: str = ""
|
|
||||||
company: Optional[CompanyData] = Field(default=None)
|
company: Optional[CompanyData] = Field(default=None)
|
||||||
|
|||||||
@ -21,9 +21,10 @@ async def list_cards(
|
|||||||
service: CardService = Depends(),
|
service: CardService = Depends(),
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"),
|
search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"),
|
||||||
|
is_wildcard: bool | None = Query(None, description="탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드"),
|
||||||
pg: PageParams = Depends(),
|
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="협상카드 등록")
|
@router.post(path="/create", response_model=Res_Card, summary="협상카드 등록")
|
||||||
|
|||||||
@ -44,7 +44,7 @@ class CardData(WebPacketProtocol):
|
|||||||
number: Optional[str] = None
|
number: Optional[str] = None
|
||||||
script: Optional[str] = None
|
script: Optional[str] = None
|
||||||
edit_script: Optional[Any] = None
|
edit_script: Optional[Any] = None
|
||||||
status: int = CardStatus.ACTIVE.value
|
status: CardStatus = CardStatus.ACTIVE
|
||||||
condition: Optional[str] = None
|
condition: Optional[str] = None
|
||||||
memo: Optional[str] = None
|
memo: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@ -57,6 +57,8 @@ class Res_Card(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_CardList(Res_PageProtocol):
|
class Res_CardList(Res_PageProtocol):
|
||||||
cards: list[CardData] = []
|
cards: list[CardData] = []
|
||||||
|
total_nego: int = 0 # 협상카드 탭 카운트(검색 필터 반영)
|
||||||
|
total_wild: int = 0 # 와일드카드 탭 카운트(검색 필터 반영)
|
||||||
|
|
||||||
|
|
||||||
class Res_DeleteCard(Res_WebPacketProtocol):
|
class Res_DeleteCard(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -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]] = {}
|
|
||||||
@ -4,6 +4,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
|
from common.enums import DeliveryType
|
||||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
@ -71,7 +72,7 @@ class ItemData(WebPacketProtocol):
|
|||||||
moq: Optional[str] = None
|
moq: Optional[str] = None
|
||||||
lead_time: Optional[int] = None
|
lead_time: Optional[int] = None
|
||||||
quantity_unit: Optional[str] = None
|
quantity_unit: Optional[str] = None
|
||||||
delivery_type: Optional[int] = None
|
delivery_type: Optional[DeliveryType] = None
|
||||||
vat_yn: Optional[bool] = None
|
vat_yn: Optional[bool] = None
|
||||||
delivery_fee_yn: Optional[bool] = None
|
delivery_fee_yn: Optional[bool] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
|
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus
|
||||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
@ -39,9 +40,9 @@ class QuotationData(WebPacketProtocol):
|
|||||||
version_id: uuid.UUID
|
version_id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
number: str
|
number: str
|
||||||
type: int
|
type: QuotationType
|
||||||
round: int = 1
|
round: int = 1
|
||||||
status: int
|
status: QuotationStatus
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
end_time: datetime
|
end_time: datetime
|
||||||
manager_name: Optional[str] = None
|
manager_name: Optional[str] = None
|
||||||
@ -82,15 +83,15 @@ class SessionData(WebPacketProtocol):
|
|||||||
item_id: uuid.UUID
|
item_id: uuid.UUID
|
||||||
qt_number: str
|
qt_number: str
|
||||||
qt_round: int
|
qt_round: int
|
||||||
qt_type: int
|
qt_type: QuotationType
|
||||||
target_price: int
|
target_price: int
|
||||||
status: int
|
status: SessionStatus
|
||||||
bid_price: Optional[int] = None
|
bid_price: Optional[int] = None
|
||||||
bid_at: Optional[datetime] = None
|
bid_at: Optional[datetime] = None
|
||||||
end_time: datetime
|
end_time: datetime
|
||||||
reject_reason: Optional[str] = None
|
reject_reason: Optional[str] = None
|
||||||
reject_price: Optional[int] = 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 로 구성
|
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
|
||||||
|
|
||||||
|
|
||||||
@ -124,11 +125,13 @@ class ChatMessageData(WebPacketProtocol):
|
|||||||
session_id: uuid.UUID
|
session_id: uuid.UUID
|
||||||
card_id: Optional[uuid.UUID] = None
|
card_id: Optional[uuid.UUID] = None
|
||||||
index: int
|
index: int
|
||||||
sender: int
|
sender: ChatSender
|
||||||
target_price: int
|
target_price: int
|
||||||
card_used_yn: Optional[bool] = None
|
card_used_yn: Optional[bool] = None
|
||||||
indicator_value: Optional[float] = 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):
|
class Res_SessionChat(Res_WebPacketProtocol):
|
||||||
@ -150,7 +153,7 @@ class QuotationCardData(WebPacketProtocol):
|
|||||||
qt_id: Optional[uuid.UUID] = None
|
qt_id: Optional[uuid.UUID] = None
|
||||||
nego_card_id: Optional[uuid.UUID] = None
|
nego_card_id: Optional[uuid.UUID] = None
|
||||||
wild_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
|
number: Optional[str] = None
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
script: Optional[str] = None # 협상 멘트(평문)
|
script: Optional[str] = None # 협상 멘트(평문)
|
||||||
|
|||||||
@ -31,13 +31,14 @@ router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {
|
|||||||
async def list_quotations(
|
async def list_quotations(
|
||||||
service: QuotationService = Depends(),
|
service: QuotationService = Depends(),
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
search: str | None = Query(None, description="견적명/견적번호 검색"),
|
||||||
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
|
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
|
||||||
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
|
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
|
||||||
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
|
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
|
||||||
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
|
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
|
||||||
pg: PageParams = Depends(),
|
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="견적 생성")
|
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")
|
||||||
|
|||||||
74
negodata/backend/scheduler/__init__.py
Normal file
74
negodata/backend/scheduler/__init__.py
Normal 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")
|
||||||
124
negodata/backend/scheduler/jobs.py
Normal file
124
negodata/backend/scheduler/jobs.py
Normal 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
|
||||||
@ -4,7 +4,7 @@ from fastapi import Depends
|
|||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import users
|
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.logger import LOG
|
||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.user_crud import IUserCRUD, UserCRUD
|
from crud.user_crud import IUserCRUD, UserCRUD
|
||||||
@ -156,7 +156,6 @@ class AuthService:
|
|||||||
res.email = user.email
|
res.email = user.email
|
||||||
res.contact_number = user.contact_number
|
res.contact_number = user.contact_number
|
||||||
res.role = user.role
|
res.role = user.role
|
||||||
res.role_label = ENUM_LABELS.get(UserRole(user.role), str(user.role))
|
|
||||||
res.company = company
|
res.company = company
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
@ -79,18 +79,23 @@ class CardService:
|
|||||||
return ErrorType.CARD_NOT_FOUND, None, None, None, False
|
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)
|
res = Res_CardList(page=pg.page, size=pg.size)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return res
|
return res
|
||||||
user_uuid = uuid.UUID(user_id)
|
user_uuid = uuid.UUID(user_id)
|
||||||
# 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분).
|
# 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분).
|
||||||
fetch = pg.skip + pg.size
|
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(
|
err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda(
|
||||||
nego_cards.DBType(),
|
nego_cards.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
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:
|
if err_n != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_n)
|
res.result.SetResult(err_n)
|
||||||
@ -99,7 +104,7 @@ class CardService:
|
|||||||
err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda(
|
err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda(
|
||||||
wild_cards.DBType(),
|
wild_cards.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
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:
|
if err_w != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_w)
|
res.result.SetResult(err_w)
|
||||||
@ -108,6 +113,14 @@ class CardService:
|
|||||||
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
|
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)
|
merged.sort(key=lambda c: c.created_at or "", reverse=True)
|
||||||
res.cards = merged[pg.skip : pg.skip + pg.size]
|
res.cards = merged[pg.skip : pg.skip + pg.size]
|
||||||
|
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
|
res.total = total_n + total_w
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
@ -82,13 +82,13 @@ class QuotationService:
|
|||||||
return ErrorType.QUOTATION_NOT_FOUND, None
|
return ErrorType.QUOTATION_NOT_FOUND, None
|
||||||
return ErrorType.SUCCESS, quotation
|
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)
|
res = Res_QuotationList(page=pg.page, size=pg.size)
|
||||||
|
|
||||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
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:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
@ -274,10 +274,16 @@ class QuotationService:
|
|||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# 상태를 '견적마감'으로 변경(실제 DB 업데이트)
|
# 견적 '견적마감'(CLOSED) + 딸린 세션 정리를 한 트랜잭션으로.
|
||||||
|
# 세션은 아직 시작 전(협상생성)인 것만 미참여로 떨군다. 협상중/완료/거부/미참여는 그대로 둔다.
|
||||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
[quotations.DBType()],
|
[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:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
@ -387,6 +393,7 @@ class QuotationService:
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
# chats.seq → ChatMessageData.index 로 매핑. indicator_value(Decimal) → float.
|
# chats.seq → ChatMessageData.index 로 매핑. indicator_value(Decimal) → float.
|
||||||
|
# 말풍선 텍스트는 chats.meta.script 에 영속화돼 있어 그대로 꺼낸다(프론트 하드코딩 X).
|
||||||
res.messages = [
|
res.messages = [
|
||||||
ChatMessageData(
|
ChatMessageData(
|
||||||
chat_id=r.chat_id,
|
chat_id=r.chat_id,
|
||||||
@ -398,6 +405,8 @@ class QuotationService:
|
|||||||
card_used_yn=r.card_used_yn,
|
card_used_yn=r.card_used_yn,
|
||||||
indicator_value=float(r.indicator_value) if r.indicator_value is not None else None,
|
indicator_value=float(r.indicator_value) if r.indicator_value is not None else None,
|
||||||
card_type=r.card_type,
|
card_type=r.card_type,
|
||||||
|
script=(r.meta or {}).get("script"),
|
||||||
|
step=(r.meta or {}).get("step"),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
# 또는 uvicorn 직접 실행:
|
# 또는 uvicorn 직접 실행:
|
||||||
# uvicorn router.router:app --reload --host=0.0.0.0 --port=9400
|
# uvicorn router.router:app --reload --host=0.0.0.0 --port=9400
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
@ -21,21 +23,20 @@ if __name__ == "__main__":
|
|||||||
LOG.i(f"Server Port : {web_server_config.port}")
|
LOG.i(f"Server Port : {web_server_config.port}")
|
||||||
LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}")
|
LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}")
|
||||||
|
|
||||||
if web_server_config.is_ssl:
|
# RELOAD=1 (개발 컨테이너) → 소스 변경 시 자동 재기동. reload 와 workers(다중) 는 함께 못 쓰므로 분기.
|
||||||
uvicorn.run(
|
reload = os.environ.get("RELOAD") == "1"
|
||||||
"router.router:app",
|
|
||||||
|
run_kwargs = dict(
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=web_server_config.port,
|
port=web_server_config.port,
|
||||||
access_log=False,
|
access_log=False,
|
||||||
workers=web_server_config.process_count,
|
|
||||||
ssl_keyfile="./SSL/key.pem",
|
|
||||||
ssl_certfile="./SSL/cert.pem",
|
|
||||||
)
|
)
|
||||||
|
if reload:
|
||||||
|
run_kwargs["reload"] = True
|
||||||
else:
|
else:
|
||||||
uvicorn.run(
|
run_kwargs["workers"] = web_server_config.process_count
|
||||||
"router.router:app",
|
if web_server_config.is_ssl:
|
||||||
host="0.0.0.0",
|
run_kwargs["ssl_keyfile"] = "./SSL/key.pem"
|
||||||
port=web_server_config.port,
|
run_kwargs["ssl_certfile"] = "./SSL/cert.pem"
|
||||||
access_log=False,
|
|
||||||
workers=web_server_config.process_count,
|
uvicorn.run("router.router:app", **run_kwargs)
|
||||||
)
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ import type { CardDataName } from './cardDataName';
|
|||||||
import type { CardDataNumber } from './cardDataNumber';
|
import type { CardDataNumber } from './cardDataNumber';
|
||||||
import type { CardDataScript } from './cardDataScript';
|
import type { CardDataScript } from './cardDataScript';
|
||||||
import type { CardDataEditScript } from './cardDataEditScript';
|
import type { CardDataEditScript } from './cardDataEditScript';
|
||||||
|
import type { CardStatus } from './cardStatus';
|
||||||
import type { CardDataCondition } from './cardDataCondition';
|
import type { CardDataCondition } from './cardDataCondition';
|
||||||
import type { CardDataMemo } from './cardDataMemo';
|
import type { CardDataMemo } from './cardDataMemo';
|
||||||
import type { CardDataCreatedAt } from './cardDataCreatedAt';
|
import type { CardDataCreatedAt } from './cardDataCreatedAt';
|
||||||
@ -22,7 +23,7 @@ export interface CardData {
|
|||||||
number?: CardDataNumber;
|
number?: CardDataNumber;
|
||||||
script?: CardDataScript;
|
script?: CardDataScript;
|
||||||
edit_script?: CardDataEditScript;
|
edit_script?: CardDataEditScript;
|
||||||
status?: number;
|
status?: CardStatus;
|
||||||
condition?: CardDataCondition;
|
condition?: CardDataCondition;
|
||||||
memo?: CardDataMemo;
|
memo?: CardDataMemo;
|
||||||
created_at?: CardDataCreatedAt;
|
created_at?: CardDataCreatedAt;
|
||||||
|
|||||||
18
negodata/front/src/api/generated/model/cardStatus.ts
Normal file
18
negodata/front/src/api/generated/model/cardStatus.ts
Normal 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;
|
||||||
18
negodata/front/src/api/generated/model/cardType.ts
Normal file
18
negodata/front/src/api/generated/model/cardType.ts
Normal 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;
|
||||||
@ -5,18 +5,23 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { ChatMessageDataCardId } from './chatMessageDataCardId';
|
import type { ChatMessageDataCardId } from './chatMessageDataCardId';
|
||||||
|
import type { ChatSender } from './chatSender';
|
||||||
import type { ChatMessageDataCardUsedYn } from './chatMessageDataCardUsedYn';
|
import type { ChatMessageDataCardUsedYn } from './chatMessageDataCardUsedYn';
|
||||||
import type { ChatMessageDataIndicatorValue } from './chatMessageDataIndicatorValue';
|
import type { ChatMessageDataIndicatorValue } from './chatMessageDataIndicatorValue';
|
||||||
import type { ChatMessageDataCardType } from './chatMessageDataCardType';
|
import type { ChatMessageDataCardType } from './chatMessageDataCardType';
|
||||||
|
import type { ChatMessageDataScript } from './chatMessageDataScript';
|
||||||
|
import type { ChatMessageDataStep } from './chatMessageDataStep';
|
||||||
|
|
||||||
export interface ChatMessageData {
|
export interface ChatMessageData {
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
card_id?: ChatMessageDataCardId;
|
card_id?: ChatMessageDataCardId;
|
||||||
index: number;
|
index: number;
|
||||||
sender: number;
|
sender: ChatSender;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
card_used_yn?: ChatMessageDataCardUsedYn;
|
card_used_yn?: ChatMessageDataCardUsedYn;
|
||||||
indicator_value?: ChatMessageDataIndicatorValue;
|
indicator_value?: ChatMessageDataIndicatorValue;
|
||||||
card_type?: ChatMessageDataCardType;
|
card_type?: ChatMessageDataCardType;
|
||||||
|
script?: ChatMessageDataScript;
|
||||||
|
step?: ChatMessageDataStep;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { CardType } from './cardType';
|
||||||
|
|
||||||
export type ChatMessageDataCardType = number | null;
|
export type ChatMessageDataCardType = CardType | null;
|
||||||
|
|||||||
@ -5,8 +5,4 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface EnumOption {
|
export type ChatMessageDataScript = string | null;
|
||||||
value: number;
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
@ -5,4 +5,4 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ResEnumsMsg = string | null;
|
export type ChatMessageDataStep = string | null;
|
||||||
18
negodata/front/src/api/generated/model/chatSender.ts
Normal file
18
negodata/front/src/api/generated/model/chatSender.ts
Normal 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;
|
||||||
19
negodata/front/src/api/generated/model/deliveryType.ts
Normal file
19
negodata/front/src/api/generated/model/deliveryType.ts
Normal 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;
|
||||||
@ -17,13 +17,18 @@ export * from './cardDataNumber';
|
|||||||
export * from './cardDataScript';
|
export * from './cardDataScript';
|
||||||
export * from './cardDataUpdatedAt';
|
export * from './cardDataUpdatedAt';
|
||||||
export * from './cardDataUserId';
|
export * from './cardDataUserId';
|
||||||
|
export * from './cardStatus';
|
||||||
|
export * from './cardType';
|
||||||
export * from './chatMessageData';
|
export * from './chatMessageData';
|
||||||
export * from './chatMessageDataCardId';
|
export * from './chatMessageDataCardId';
|
||||||
export * from './chatMessageDataCardType';
|
export * from './chatMessageDataCardType';
|
||||||
export * from './chatMessageDataCardUsedYn';
|
export * from './chatMessageDataCardUsedYn';
|
||||||
export * from './chatMessageDataIndicatorValue';
|
export * from './chatMessageDataIndicatorValue';
|
||||||
|
export * from './chatMessageDataScript';
|
||||||
|
export * from './chatMessageDataStep';
|
||||||
|
export * from './chatSender';
|
||||||
export * from './companyData';
|
export * from './companyData';
|
||||||
export * from './enumOption';
|
export * from './deliveryType';
|
||||||
export * from './errorInfo';
|
export * from './errorInfo';
|
||||||
export * from './errorInfoCode';
|
export * from './errorInfoCode';
|
||||||
export * from './errorInfoDesc';
|
export * from './errorInfoDesc';
|
||||||
@ -80,6 +85,8 @@ export * from './quotationSettingData';
|
|||||||
export * from './quotationSettingDataCreatedAt';
|
export * from './quotationSettingDataCreatedAt';
|
||||||
export * from './quotationSettingDataUpdatedAt';
|
export * from './quotationSettingDataUpdatedAt';
|
||||||
export * from './quotationSettingDataUserId';
|
export * from './quotationSettingDataUserId';
|
||||||
|
export * from './quotationStatus';
|
||||||
|
export * from './quotationType';
|
||||||
export * from './reqCheckCodes';
|
export * from './reqCheckCodes';
|
||||||
export * from './reqCreateAccount';
|
export * from './reqCreateAccount';
|
||||||
export * from './reqCreateCard';
|
export * from './reqCreateCard';
|
||||||
@ -179,9 +186,6 @@ export * from './resDeleteQuotationSetting';
|
|||||||
export * from './resDeleteQuotationSettingMsg';
|
export * from './resDeleteQuotationSettingMsg';
|
||||||
export * from './resDeleteSupplier';
|
export * from './resDeleteSupplier';
|
||||||
export * from './resDeleteSupplierMsg';
|
export * from './resDeleteSupplierMsg';
|
||||||
export * from './resEnums';
|
|
||||||
export * from './resEnumsEnums';
|
|
||||||
export * from './resEnumsMsg';
|
|
||||||
export * from './resItem';
|
export * from './resItem';
|
||||||
export * from './resItemCategories';
|
export * from './resItemCategories';
|
||||||
export * from './resItemCategoriesMsg';
|
export * from './resItemCategoriesMsg';
|
||||||
@ -248,6 +252,7 @@ export * from './sessionDataBidPrice';
|
|||||||
export * from './sessionDataRejectDeliveryType';
|
export * from './sessionDataRejectDeliveryType';
|
||||||
export * from './sessionDataRejectPrice';
|
export * from './sessionDataRejectPrice';
|
||||||
export * from './sessionDataRejectReason';
|
export * from './sessionDataRejectReason';
|
||||||
|
export * from './sessionStatus';
|
||||||
export * from './supplierData';
|
export * from './supplierData';
|
||||||
export * from './supplierDataCode';
|
export * from './supplierDataCode';
|
||||||
export * from './supplierDataCreatedAt';
|
export * from './supplierDataCreatedAt';
|
||||||
@ -256,6 +261,7 @@ export * from './supplierDataManagerEmail';
|
|||||||
export * from './supplierDataManagerName';
|
export * from './supplierDataManagerName';
|
||||||
export * from './supplierDataPriority';
|
export * from './supplierDataPriority';
|
||||||
export * from './supplierDataUpdatedAt';
|
export * from './supplierDataUpdatedAt';
|
||||||
|
export * from './userRole';
|
||||||
export * from './validationError';
|
export * from './validationError';
|
||||||
export * from './validationErrorCtx';
|
export * from './validationErrorCtx';
|
||||||
export * from './validationErrorLocItem';
|
export * from './validationErrorLocItem';
|
||||||
@ -4,5 +4,6 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { DeliveryType } from './deliveryType';
|
||||||
|
|
||||||
export type ItemDataDeliveryType = number | null;
|
export type ItemDataDeliveryType = DeliveryType | null;
|
||||||
|
|||||||
@ -10,6 +10,10 @@ export type ListCardsParams = {
|
|||||||
* 카드명/카드번호/스크립트 검색
|
* 카드명/카드번호/스크립트 검색
|
||||||
*/
|
*/
|
||||||
search?: string | null;
|
search?: string | null;
|
||||||
|
/**
|
||||||
|
* 탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드
|
||||||
|
*/
|
||||||
|
is_wildcard?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* @minimum 1
|
* @minimum 1
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,6 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type ListQuotationsParams = {
|
export type ListQuotationsParams = {
|
||||||
|
/**
|
||||||
|
* 견적명/견적번호 검색
|
||||||
|
*/
|
||||||
|
search?: string | null;
|
||||||
/**
|
/**
|
||||||
* 상태 필터(정확히 일치)
|
* 상태 필터(정확히 일치)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { CardType } from './cardType';
|
||||||
|
|
||||||
export type QuotationCardDataType = number | null;
|
export type QuotationCardDataType = CardType | null;
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { QuotationType } from './quotationType';
|
||||||
|
import type { QuotationStatus } from './quotationStatus';
|
||||||
import type { QuotationDataManagerName } from './quotationDataManagerName';
|
import type { QuotationDataManagerName } from './quotationDataManagerName';
|
||||||
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
|
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
|
||||||
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
|
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
|
||||||
@ -25,9 +27,9 @@ export interface QuotationData {
|
|||||||
version_id: string;
|
version_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
number: string;
|
number: string;
|
||||||
type: number;
|
type: QuotationType;
|
||||||
round?: number;
|
round?: number;
|
||||||
status: number;
|
status: QuotationStatus;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
manager_name?: QuotationDataManagerName;
|
manager_name?: QuotationDataManagerName;
|
||||||
|
|||||||
20
negodata/front/src/api/generated/model/quotationStatus.ts
Normal file
20
negodata/front/src/api/generated/model/quotationStatus.ts
Normal 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;
|
||||||
18
negodata/front/src/api/generated/model/quotationType.ts
Normal file
18
negodata/front/src/api/generated/model/quotationType.ts
Normal 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;
|
||||||
@ -15,4 +15,6 @@ export interface ResCardList {
|
|||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
cards?: CardData[];
|
cards?: CardData[];
|
||||||
|
total_nego?: number;
|
||||||
|
total_wild?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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[]};
|
|
||||||
@ -9,6 +9,7 @@ import type { ResMeMsg } from './resMeMsg';
|
|||||||
import type { ResMeName } from './resMeName';
|
import type { ResMeName } from './resMeName';
|
||||||
import type { ResMeEmail } from './resMeEmail';
|
import type { ResMeEmail } from './resMeEmail';
|
||||||
import type { ResMeContactNumber } from './resMeContactNumber';
|
import type { ResMeContactNumber } from './resMeContactNumber';
|
||||||
|
import type { UserRole } from './userRole';
|
||||||
import type { ResMeCompany } from './resMeCompany';
|
import type { ResMeCompany } from './resMeCompany';
|
||||||
|
|
||||||
export interface ResMe {
|
export interface ResMe {
|
||||||
@ -19,7 +20,6 @@ export interface ResMe {
|
|||||||
name?: ResMeName;
|
name?: ResMeName;
|
||||||
email?: ResMeEmail;
|
email?: ResMeEmail;
|
||||||
contact_number?: ResMeContactNumber;
|
contact_number?: ResMeContactNumber;
|
||||||
role?: number;
|
role?: UserRole;
|
||||||
role_label?: string;
|
|
||||||
company?: ResMeCompany;
|
company?: ResMeCompany;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { QuotationType } from './quotationType';
|
||||||
|
import type { SessionStatus } from './sessionStatus';
|
||||||
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
||||||
import type { SessionDataBidAt } from './sessionDataBidAt';
|
import type { SessionDataBidAt } from './sessionDataBidAt';
|
||||||
import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
import type { SessionDataRejectReason } from './sessionDataRejectReason';
|
||||||
@ -17,9 +19,9 @@ export interface SessionData {
|
|||||||
item_id: string;
|
item_id: string;
|
||||||
qt_number: string;
|
qt_number: string;
|
||||||
qt_round: number;
|
qt_round: number;
|
||||||
qt_type: number;
|
qt_type: QuotationType;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
status: number;
|
status: SessionStatus;
|
||||||
bid_price?: SessionDataBidPrice;
|
bid_price?: SessionDataBidPrice;
|
||||||
bid_at?: SessionDataBidAt;
|
bid_at?: SessionDataBidAt;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
* Negodata Api Server
|
* Negodata Api Server
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { DeliveryType } from './deliveryType';
|
||||||
|
|
||||||
export type SessionDataRejectDeliveryType = number | null;
|
export type SessionDataRejectDeliveryType = DeliveryType | null;
|
||||||
|
|||||||
21
negodata/front/src/api/generated/model/sessionStatus.ts
Normal file
21
negodata/front/src/api/generated/model/sessionStatus.ts
Normal 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;
|
||||||
18
negodata/front/src/api/generated/model/userRole.ts
Normal file
18
negodata/front/src/api/generated/model/userRole.ts
Normal 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;
|
||||||
@ -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';
|
import { Upload, Image as ImageIcon, X, AlertCircle, Link2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ImageDropzoneProps {
|
interface ImageDropzoneProps {
|
||||||
@ -31,6 +31,12 @@ export default function ImageDropzone({
|
|||||||
const [urlDraft, setUrlDraft] = useState('');
|
const [urlDraft, setUrlDraft] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 현재 값이 일반 URL 이면 입력칸에 그대로 노출(보기/수정/복사 가능).
|
||||||
|
// base64 data URL(파일 업로드 폴백)은 거대 문자열이라 칸엔 넣지 않는다.
|
||||||
|
useEffect(() => {
|
||||||
|
setUrlDraft(value && /^https?:\/\//i.test(value) ? value : '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const processFile = async (file: File) => {
|
const processFile = async (file: File) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@ -107,8 +113,7 @@ export default function ImageDropzone({
|
|||||||
const url = urlDraft.trim();
|
const url = urlDraft.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
onChange(url);
|
onChange(url); // value 변경 → 위 effect 가 입력칸을 적용된 URL 로 다시 채움
|
||||||
setUrlDraft('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -97,10 +97,7 @@ export function DataTable<T>({
|
|||||||
const detailCols = mobileCols.filter((c) => c !== primaryCol)
|
const detailCols = mobileCols.filter((c) => c !== primaryCol)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 전환 기준은 뷰포트가 아니라 '표가 들어갈 실제 폭'(컨테이너). 사이드바가 폭을 먹어도
|
|
||||||
// 어긋나지 않는다. 컨테이너 ≥ 48rem(@3xl)이면 표, 그 미만은 카드 리스트. (Tailwind v4 내장 @container)
|
|
||||||
<div className={cn("@container border border-border rounded-lg bg-card overflow-hidden", className)}>
|
<div className={cn("@container border border-border rounded-lg bg-card overflow-hidden", className)}>
|
||||||
{/* 넓을 때: 표 — 그래도 넘치면 Table 내부에서 가로 스크롤 */}
|
|
||||||
<div className="hidden @3xl:block">
|
<div className="hidden @3xl:block">
|
||||||
<Table className="w-full text-xs">
|
<Table className="w-full text-xs">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -184,7 +181,6 @@ export function DataTable<T>({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 좁을 때(컨테이너 < 48rem): 카드 리스트 — 행=카드, 컬럼=라벨:값 */}
|
|
||||||
<div className="@3xl:hidden divide-y divide-border">
|
<div className="@3xl:hidden divide-y divide-border">
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
data.map((row) => {
|
data.map((row) => {
|
||||||
@ -218,7 +214,6 @@ export function DataTable<T>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 상세: 기본=라벨:값 한 줄(컴팩트) / mobileBlock=라벨 아래 풀폭 */}
|
|
||||||
{detailCols.length > 0 && (
|
{detailCols.length > 0 && (
|
||||||
<dl className="mt-2.5 space-y-1.5 border-t border-border/40 pt-2.5 text-[11px] leading-tight">
|
<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) =>
|
{detailCols.map((c, i) =>
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
import type {ResMe} from '../../api/generated/model/resMe';
|
import type {ResMe} from '../../api/generated/model/resMe';
|
||||||
import type {ErrorInfo} from '../../api/generated/model/errorInfo';
|
import type {ErrorInfo} from '../../api/generated/model/errorInfo';
|
||||||
import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
|
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 ACCESS_KEY = 'negodata.accessToken';
|
||||||
const REFRESH_KEY = 'negodata.refreshToken';
|
const REFRESH_KEY = 'negodata.refreshToken';
|
||||||
@ -26,7 +28,7 @@ function toAuthUser(me: ResMe): AuthUser {
|
|||||||
loginId: me.id ?? '',
|
loginId: me.id ?? '',
|
||||||
email: me.email ?? '',
|
email: me.email ?? '',
|
||||||
contact: me.contact_number ?? '',
|
contact: me.contact_number ?? '',
|
||||||
role: (me.role_label as UserRole) || '일반',
|
role: (USER_ROLE_LABEL[me.role ?? UserRoleCode.USER] ?? '일반') as UserRole,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,10 +54,11 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '스크립트',
|
header: '스크립트',
|
||||||
|
headClassName: 'w-[22rem]', // 컬럼 폭 고정 → 긴 스크립트가 표를 늘리지 않게
|
||||||
cellClassName: 'font-mono text-muted-foreground',
|
cellClassName: 'font-mono text-muted-foreground',
|
||||||
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
|
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
|
||||||
cell: (card) => (
|
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}
|
{card.scriptPreview}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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 };
|
|
||||||
}
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
useListCards,
|
useListCards,
|
||||||
createCard,
|
createCard,
|
||||||
updateCard,
|
updateCard,
|
||||||
deleteCard,
|
deleteCard,
|
||||||
getListCardsQueryKey,
|
|
||||||
} from '@/api/generated/card/card';
|
} from '@/api/generated/card/card';
|
||||||
|
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
|
||||||
import type { Descendant } from 'slate';
|
import type { Descendant } from 'slate';
|
||||||
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
|
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
|
||||||
import type { ResCard } from '@/api/generated/model/resCard';
|
import type { ResCard } from '@/api/generated/model/resCard';
|
||||||
@ -13,8 +13,6 @@ import type { NegotiationCard } from '@/types';
|
|||||||
import { mapCardData, toCardStatusCode } from '../types';
|
import { mapCardData, toCardStatusCode } from '../types';
|
||||||
import { serializeToText } from '../editor';
|
import { serializeToText } from '../editor';
|
||||||
|
|
||||||
const LIST_PARAMS = { size: 100 };
|
|
||||||
|
|
||||||
// 카드 폼이 넘기는 입력값(편집/생성 공통).
|
// 카드 폼이 넘기는 입력값(편집/생성 공통).
|
||||||
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
|
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
|
||||||
export type CardInput = {
|
export type CardInput = {
|
||||||
@ -50,12 +48,13 @@ function toReq(input: CardInput): ReqCreateCard {
|
|||||||
|
|
||||||
// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
|
// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
|
||||||
// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리.
|
// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리.
|
||||||
export function useCards() {
|
export function useCards(params: ListCardsParams) {
|
||||||
const queryClient = useQueryClient();
|
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 createCardFn = async (input: CardInput) => {
|
||||||
const msg = cardError(await createCard(toReq(input)));
|
const msg = cardError(await createCard(toReq(input)));
|
||||||
@ -74,9 +73,15 @@ export function useCards() {
|
|||||||
|
|
||||||
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
|
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
|
||||||
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
|
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 {
|
return {
|
||||||
cards,
|
cards,
|
||||||
|
total,
|
||||||
|
totalNego,
|
||||||
|
totalWild,
|
||||||
createCard: createCardFn,
|
createCard: createCardFn,
|
||||||
updateCard: updateCardFn,
|
updateCard: updateCardFn,
|
||||||
deleteCard: deleteCardFn,
|
deleteCard: deleteCardFn,
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import type { NegotiationCard } from '@/types';
|
import type { NegotiationCard } from '@/types';
|
||||||
import type { CardData } from '@/api/generated/model/cardData';
|
import type { CardData } from '@/api/generated/model/cardData';
|
||||||
|
import { CardStatus } from '@/api/generated/model';
|
||||||
|
|
||||||
export type { NegotiationCard };
|
export type { NegotiationCard };
|
||||||
|
|
||||||
// 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드.
|
// 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드.
|
||||||
export type CardTab = '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') =>
|
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' =>
|
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.
|
// 서버 CardData(nego_cards/wild_cards 통합) → UI NegotiationCard.
|
||||||
export function mapCardData(c: CardData): NegotiationCard {
|
export function mapCardData(c: CardData): NegotiationCard {
|
||||||
|
|||||||
@ -27,9 +27,7 @@ function supplierError(res: ResSupplier): string | null {
|
|||||||
return r.desc || '협력사 등록에 실패했습니다.';
|
return r.desc || '협력사 등록에 실패했습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 협력사 서버 데이터 + CRUD.
|
|
||||||
// - params: 테이블용 서버 페이지네이션/검색/우선순위 (useServerList 가 만든다)
|
|
||||||
// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리.
|
|
||||||
export function usePartners(params: ListSuppliersParams) {
|
export function usePartners(params: ListSuppliersParams) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
import type { SupplierData } from '@/api/generated/model/supplierData';
|
export type { Partner } from '@/types';
|
||||||
|
|
||||||
// UI에서 쓰는 협력사 타입. 서버 SupplierData에 화면 전용 파생 필드만 얹는다.
|
|
||||||
// (level/rank/status 등 서버 미연동 가짜 필드는 두지 않는다 — 표시 가능한 건 priority뿐.)
|
|
||||||
export type Partner = SupplierData & {
|
|
||||||
deleted?: boolean;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외).
|
// 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외).
|
||||||
export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW'];
|
export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW'];
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
||||||
import type { ReqUpdateItem as ItemUpdate } from '@/api/generated/model/reqUpdateItem';
|
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 { uploadItemImage } from '@/api/generated/item/item';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import ImageDropzone from '@/components/ImageDropzone';
|
import ImageDropzone from '@/components/ImageDropzone';
|
||||||
@ -121,9 +121,7 @@ export function ProductFormSheet({
|
|||||||
defaultValues: buildDefaults(mode, product),
|
defaultValues: buildDefaults(mode, product),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 배송 형태 코드(delivery_type) 선택지는 서버 enum 에서 가져온다.
|
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
|
||||||
const { data: enumsData } = useListEnums();
|
|
||||||
const deliveryTypes = enumsData?.enums?.delivery_type ?? [];
|
|
||||||
|
|
||||||
// minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로.
|
// minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로.
|
||||||
const onValid = async (v: FormValues) => {
|
const onValid = async (v: FormValues) => {
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export function ProductTable({
|
|||||||
rowKey={(prod) => prod.item_id}
|
rowKey={(prod) => prod.item_id}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
selection={{ selectedKeys: selectedIds, onSelectionChange }}
|
selection={{ selectedKeys: selectedIds, onSelectionChange }}
|
||||||
empty="부합하는 B2B 상품 데이터 정보가 식별되지 않습니다."
|
empty="부합하는 상품 데이터 정보가 식별되지 않습니다."
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
page={page}
|
page={page}
|
||||||
|
|||||||
@ -1,14 +1,4 @@
|
|||||||
import type { ItemData } from '@/api/generated/model/itemData';
|
export type { Product } from '@/types';
|
||||||
|
|
||||||
// UI에서 쓰는 상품 타입. 서버 ItemData에 화면 전용 파생 필드를 얹는다.
|
|
||||||
export type Product = ItemData & {
|
|
||||||
deleted?: boolean;
|
|
||||||
id?: string;
|
|
||||||
minPrice?: number;
|
|
||||||
status?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 분류 카테고리는 서버 items 에서 distinct 로 파생한다(useProducts). 하드코딩 상수 제거됨.
|
|
||||||
|
|
||||||
// 인터넷 최저가 데모 산정(표준 단가의 83%). 서버 미연동 — 표시/초기값 용도.
|
// 인터넷 최저가 데모 산정(표준 단가의 83%). 서버 미연동 — 표시/초기값 용도.
|
||||||
export const toMinPrice = (price?: number | null) => Math.round((price || 0) * 0.83);
|
export const toMinPrice = (price?: number | null) => Math.round((price || 0) * 0.83);
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||||
|
import { QuotationType } from '@/api/generated/model';
|
||||||
|
import { QUOTATION_TYPE_OPTIONS } from '../types';
|
||||||
|
|
||||||
type CreateQuotationWizardProps = {
|
type QuotationCreateModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
partners: Partner[];
|
partners: Partner[];
|
||||||
@ -17,7 +19,7 @@ type CreateQuotationWizardProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CreateQuotationWizard({
|
export function QuotationCreateModal({
|
||||||
open,
|
open,
|
||||||
products,
|
products,
|
||||||
partners,
|
partners,
|
||||||
@ -25,22 +27,25 @@ export function CreateQuotationWizard({
|
|||||||
quotationSettings,
|
quotationSettings,
|
||||||
onCreate,
|
onCreate,
|
||||||
onClose,
|
onClose,
|
||||||
}: CreateQuotationWizardProps) {
|
}: QuotationCreateModalProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [title, setTitle] = useState('');
|
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 [productId, setProductId] = useState('');
|
||||||
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
|
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
|
||||||
const [dueDate, setDueDate] = useState('2026-06-15T18:00');
|
const [dueDate, setDueDate] = useState('2026-06-15T18:00');
|
||||||
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
||||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const typeOptions = QUOTATION_TYPE_OPTIONS;
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const togglePartner = (id: string) =>
|
const togglePartner = (id: string) =>
|
||||||
setSelectedPartnerIds((prev) =>
|
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) =>
|
const toggleCard = (id: string) =>
|
||||||
setSelectedCardIds((prev) =>
|
setSelectedCardIds((prev) =>
|
||||||
@ -73,8 +78,8 @@ export function CreateQuotationWizard({
|
|||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
<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">
|
<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} />
|
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
|
||||||
<span className="text-sm font-semibold text-foreground font-mono">협상견적 생성 중…</span>
|
<Typography variant="small" className="font-semibold font-mono">협상견적 생성 중…</Typography>
|
||||||
<span className="text-[11px] text-muted-foreground font-mono">견적 · 협상 세션 등록 중</span>
|
<Typography variant="small" className="text-muted-foreground font-mono">견적 · 협상 세션 등록 중</Typography>
|
||||||
</div>
|
</div>
|
||||||
</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 justify-between pb-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PlusSquare className="text-foreground" size={18} />
|
<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>
|
</div>
|
||||||
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@ -93,11 +98,11 @@ export function CreateQuotationWizard({
|
|||||||
|
|
||||||
{/* Steps indicator */}
|
{/* Steps indicator */}
|
||||||
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-[10px] text-muted-foreground">
|
<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} />
|
<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} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Step content */}
|
{/* Step content */}
|
||||||
@ -121,17 +126,22 @@ export function CreateQuotationWizard({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">유형</Typography>
|
<Typography as="label" variant="label">유형</Typography>
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={String(type)}
|
||||||
onValueChange={(v) => setType(v as 'RE_NEGOTIATION' | 'RE_ESTIMATE')}
|
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">
|
<SelectTrigger id="wizard-type" className="w-full">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value) => (value === 'RE_ESTIMATE' ? '재견적' : '재협상')}
|
{(value) => typeOptions.find((o) => String(o.value) === value)?.label ?? ''}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="RE_NEGOTIATION">재협상</SelectItem>
|
{typeOptions.map((o) => (
|
||||||
<SelectItem value="RE_ESTIMATE">재견적</SelectItem>
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -175,7 +185,7 @@ export function CreateQuotationWizard({
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
||||||
{partners.map((part) => {
|
{partners.map((part) => {
|
||||||
const isChecked = selectedPartnerIds.includes(part.id ?? '');
|
const isChecked = selectedPartnerIds.includes(part.id ?? '');
|
||||||
@ -192,8 +202,8 @@ export function CreateQuotationWizard({
|
|||||||
className="accent-primary h-4 w-4"
|
className="accent-primary h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-foreground block">{part.name}</span>
|
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
|
||||||
<span className="text-[10px] text-muted-foreground">이메일: {part.managerEmail} · 등급: {part.rank}</span>
|
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {part.managerEmail} · 등급: {part.rank}</Typography>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@ -236,7 +246,7 @@ export function CreateQuotationWizard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
|
||||||
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
|
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
|
||||||
const isChecked = selectedCardIds.includes(card.id);
|
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" />
|
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
<span
|
||||||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
|
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'
|
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
|
||||||
@ -260,7 +270,7 @@ export function CreateQuotationWizard({
|
|||||||
{card.isWildcard ? '와일드' : '협상'}
|
{card.isWildcard ? '와일드' : '협상'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Clock, Building2 } from 'lucide-react';
|
import { Clock, Building2 } from 'lucide-react';
|
||||||
import { DataTable } from '@/components/ui/data-table';
|
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 = {
|
type QuotationTableProps = {
|
||||||
data: Estimate[];
|
data: Estimate[];
|
||||||
@ -10,15 +11,15 @@ type QuotationTableProps = {
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBadgeClass = (status?: string | null) => {
|
const statusBadgeClass = (status?: number | null) => {
|
||||||
switch (normalizeQuotationStatus(status)) {
|
switch (status) {
|
||||||
case '견적생성':
|
case QuotationStatus.CREATED:
|
||||||
return 'bg-yellow-50 text-yellow-700 border-yellow-300 animate-pulse';
|
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';
|
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';
|
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';
|
return 'bg-red-50 text-red-700 border-red-300';
|
||||||
default:
|
default:
|
||||||
return 'bg-zinc-100 text-zinc-600';
|
return 'bg-zinc-100 text-zinc-600';
|
||||||
@ -63,12 +64,12 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
|
|||||||
cell: (est) => (
|
cell: (est) => (
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded-full text-[9px] font-bold ${
|
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-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'
|
: 'bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{est.type === 'RE_NEGOTIATION' ? '재협상' : '재견적'}
|
{quotationTypeLabel(est.type)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -85,7 +86,7 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-[10px] font-semibold rounded-full border ${statusBadgeClass(est.status)}`}
|
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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { useListItems } from '@/api/generated/item/item';
|
||||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
import { useListCards } from '@/api/generated/card/card';
|
import { useListCards } from '@/api/generated/card/card';
|
||||||
@ -14,22 +14,20 @@ import {
|
|||||||
useListQuotations,
|
useListQuotations,
|
||||||
useCreateQuotation,
|
useCreateQuotation,
|
||||||
useStopQuotation,
|
useStopQuotation,
|
||||||
getListQuotationsQueryKey,
|
getGetQuotationQueryKey,
|
||||||
|
getGetQuotationSessionsQueryKey,
|
||||||
} from '@/api/generated/quotation/quotation';
|
} from '@/api/generated/quotation/quotation';
|
||||||
|
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
|
||||||
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
|
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 { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import type { Estimate } from '@/types';
|
import type { Estimate } from '../types';
|
||||||
import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
|
||||||
|
import { QuotationStatus } from '@/api/generated/model';
|
||||||
|
|
||||||
export type CreateQuotationInput = {
|
export type CreateQuotationInput = {
|
||||||
title: string;
|
title: string;
|
||||||
type: 'RE_NEGOTIATION' | 'RE_ESTIMATE';
|
type: number; // QuotationType 코드 (1=재협상, 2=재견적)
|
||||||
productId: string;
|
productId: string;
|
||||||
partnerIds: string[];
|
partnerIds: string[];
|
||||||
dueDate: string; // datetime-local 원본값
|
dueDate: string; // datetime-local 원본값
|
||||||
@ -46,53 +44,61 @@ export type SettingInput = {
|
|||||||
// 견적 화면 데이터 허브.
|
// 견적 화면 데이터 허브.
|
||||||
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
||||||
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
||||||
export function useQuotations() {
|
export function useQuotations(params: ListQuotationsParams) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const itemsQuery = useListItems({ size: 100 });
|
const itemsQuery = useListItems({ size: 100 });
|
||||||
const suppliersQuery = useListSuppliers({ size: 100 });
|
const suppliersQuery = useListSuppliers({ size: 100 });
|
||||||
const cardsQuery = useListCards({ size: 100 });
|
const cardsQuery = useListCards({ size: 100 });
|
||||||
const settingsQuery = useListSettings();
|
const settingsQuery = useListSettings();
|
||||||
const quotationsQuery = useListQuotations(undefined);
|
// 견적 목록은 서버 검색/상태·유형 필터/페이지네이션. 페이지 이동 시 이전 데이터 유지(깜빡임 방지).
|
||||||
|
const quotationsQuery = useListQuotations(params, { query: { placeholderData: keepPreviousData } });
|
||||||
const createSettingMutation = useCreateSetting();
|
const createSettingMutation = useCreateSetting();
|
||||||
const deleteSettingMutation = useDeleteSetting();
|
const deleteSettingMutation = useDeleteSetting();
|
||||||
const createQuotationMutation = useCreateQuotation();
|
const createQuotationMutation = useCreateQuotation();
|
||||||
const stopQuotationMutation = useStopQuotation();
|
const stopQuotationMutation = useStopQuotation();
|
||||||
|
|
||||||
|
// 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화).
|
||||||
const invalidateQuotations = () =>
|
const invalidateQuotations = () =>
|
||||||
queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) });
|
queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] });
|
||||||
|
|
||||||
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
|
const products = (itemsQuery.data?.items ?? []).map(mapItem);
|
||||||
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
|
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
|
||||||
|
|
||||||
// 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다.
|
// 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다.
|
||||||
const quotationSettings = (
|
const quotationSettings = (
|
||||||
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
|
settingsQuery.data?.settings ?? []
|
||||||
).map(mapSetting);
|
).map(mapSetting);
|
||||||
|
|
||||||
const [quotations, setQuotations] = useState<Estimate[]>([]);
|
const [quotations, setQuotations] = useState<Estimate[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
|
const qs = quotationsQuery.data?.quotations;
|
||||||
if (qs) setQuotations(qs.map(mapQuotation));
|
if (qs) setQuotations(qs.map(mapQuotation));
|
||||||
}, [quotationsQuery.data]);
|
}, [quotationsQuery.data]);
|
||||||
|
// 서버 전체 건수(선택 필터 반영) — 페이지네이션용.
|
||||||
|
const total = quotationsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
|
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
|
||||||
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
|
const cards = (cardsQuery.data?.cards ?? []).map(mapCardData);
|
||||||
|
|
||||||
// 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화.
|
// 견적 마감 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속 + 협상생성 세션은 미참여로 전이).
|
||||||
const stopNegotiation = async (id: string, name: string) => {
|
// 성공 시 목록 무효화로 서버값 재동기화.
|
||||||
if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return;
|
const closeQuotation = async (id: string, name: string) => {
|
||||||
|
if (!(await confirm({ title: '견적 마감', description: `[${name}] 견적을 마감하시겠습니까? 마감하면 진행 중인 협상이 종료되고 되돌릴 수 없습니다.`, confirmText: '마감', destructive: true }))) return;
|
||||||
// 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영.
|
// 낙관적 갱신 — 서버가 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(
|
stopQuotationMutation.mutate(
|
||||||
{ qtId: id },
|
{ qtId: id },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateQuotations();
|
invalidateQuotations();
|
||||||
showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info');
|
// 열려있는 상세 Sheet 도 즉시 동기화(단건 견적 상태 + 세션 상태 재조회).
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(id) });
|
||||||
|
showToast(`[${name}] 견적이 마감되었습니다.`, 'info');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백
|
invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백
|
||||||
showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
|
showToast('견적 마감에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -162,7 +168,7 @@ export function useQuotations() {
|
|||||||
const payload: ReqCreateQuotation = {
|
const payload: ReqCreateQuotation = {
|
||||||
qt_setting_id: input.settingId,
|
qt_setting_id: input.settingId,
|
||||||
name: input.title,
|
name: input.title,
|
||||||
type: input.type === 'RE_ESTIMATE' ? 2 : 1,
|
type: input.type,
|
||||||
end_time: new Date(input.dueDate).toISOString(),
|
end_time: new Date(input.dueDate).toISOString(),
|
||||||
item_ids: [input.productId],
|
item_ids: [input.productId],
|
||||||
supplier_ids: input.partnerIds,
|
supplier_ids: input.partnerIds,
|
||||||
@ -194,8 +200,9 @@ export function useQuotations() {
|
|||||||
partners,
|
partners,
|
||||||
cards,
|
cards,
|
||||||
quotations,
|
quotations,
|
||||||
|
total,
|
||||||
quotationSettings,
|
quotationSettings,
|
||||||
stopNegotiation,
|
closeQuotation,
|
||||||
addSetting,
|
addSetting,
|
||||||
deleteSetting,
|
deleteSetting,
|
||||||
createQuotation,
|
createQuotation,
|
||||||
|
|||||||
@ -4,39 +4,48 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
|
|||||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
import type { SessionData } from '@/api/generated/model/sessionData';
|
import type { SessionData } from '@/api/generated/model/sessionData';
|
||||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||||
import type {
|
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
|
||||||
Product,
|
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
|
||||||
Partner,
|
import { toMinPrice } from '@/features/products/types';
|
||||||
QuotationSetting,
|
import type { Product, Partner, NegotiationCard } from '@/types';
|
||||||
Estimate,
|
|
||||||
ChatSession,
|
|
||||||
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 모델 매퍼 ─────────────────────────────────────────────
|
// ── 서버 응답 → 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 {
|
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 {
|
export function mapSupplier(sp: SupplierData): Partner {
|
||||||
return {
|
return {
|
||||||
supplier_id: sp.supplier_id,
|
...sp,
|
||||||
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,
|
|
||||||
id: sp.supplier_id,
|
id: sp.supplier_id,
|
||||||
managerName: sp.manager_name || '',
|
managerName: sp.manager_name || '',
|
||||||
managerEmail: sp.manager_email || '',
|
managerEmail: sp.manager_email || '',
|
||||||
@ -66,8 +75,8 @@ export function mapQuotation(q: QuotationData): Estimate {
|
|||||||
...(q as unknown as Partial<Estimate>),
|
...(q as unknown as Partial<Estimate>),
|
||||||
id: q.qt_id,
|
id: q.qt_id,
|
||||||
title: q.name,
|
title: q.name,
|
||||||
type: normalizeQuotationType(q.type),
|
type: q.type,
|
||||||
status: normalizeQuotationStatus(q.status) || String(q.status ?? ''),
|
status: q.status,
|
||||||
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
|
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
|
||||||
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
|
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
|
||||||
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
|
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())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 견적상태 정규화(영문 enum / 한글 DDL 혼용 대응) ──────────────────────
|
|
||||||
|
|
||||||
export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
|
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 const QUOTATION_STATUS_OPTIONS = Object.values(QuotationStatus).map((value) => ({
|
||||||
export function normalizeQuotationStatus(status?: string | number | null): QtStatusKey | '' {
|
value,
|
||||||
switch (status) {
|
label: QUOTATION_STATUS_LABEL[value],
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuotationType 코드(1=재협상, 2=재견적) ↔ UI 유형값. 이미 문자열이면 그대로 통과.
|
export const QUOTATION_TYPE_LABEL: Record<QuotationType, string> = {
|
||||||
export function normalizeQuotationType(type?: string | number | null): 'RE_NEGOTIATION' | 'RE_ESTIMATE' {
|
[QuotationType.RENEGO]: '재협상',
|
||||||
if (type === 1 || type === 'RE_NEGOTIATION') return 'RE_NEGOTIATION';
|
[QuotationType.REQUOTE]: '재견적',
|
||||||
if (type === 2 || type === 'RE_ESTIMATE') return 'RE_ESTIMATE';
|
};
|
||||||
return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE';
|
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;
|
supplier_name: string;
|
||||||
item_id: string;
|
item_id: string;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
status: string;
|
status: number;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
bid_price: number | null;
|
bid_price: number | null;
|
||||||
bid_at: string;
|
bid_at: string;
|
||||||
@ -163,115 +157,38 @@ export type SessionView = {
|
|||||||
|
|
||||||
export type QuotationCardView = {
|
export type QuotationCardView = {
|
||||||
session_card_id: string;
|
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;
|
card_name: string;
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출.
|
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
|
||||||
export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryView {
|
const winnerId = q.preferred_sp_id ?? null;
|
||||||
if (est.id === 'est-1') {
|
|
||||||
return {
|
return {
|
||||||
bid_summary_id: 'bid-summary-111-uuid',
|
bid_summary_id: `bid-summary-${q.qt_id}`,
|
||||||
status: '입찰진행중 (ACTIVE)',
|
status: q.status === QuotationStatus.CLOSED ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
|
||||||
qt_iteration: 2,
|
qt_iteration: q.iteration ?? 1,
|
||||||
has_preferred: true,
|
has_preferred: !!winnerId,
|
||||||
preferred_sp_id: 'part-1',
|
preferred_sp_id: winnerId,
|
||||||
preferred_sp_name: '(주)우성테크놀로지',
|
preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'),
|
||||||
equal_data: '-',
|
equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_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: '-',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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: '-',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 협력사별 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 / 사용 카드) ──────────────
|
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────
|
||||||
|
|
||||||
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
|
// 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다.
|
||||||
// (정본은 서버 enum — 여기 값은 그걸 미러링한 것이며 드리프트 시 서버 기준으로 맞춘다.)
|
// (정본은 서버 enum — 여기 값은 그걸 미러링한 것이며 드리프트 시 서버 기준으로 맞춘다.)
|
||||||
export const SESSION_STATUS_LABEL: Record<number, string> = {
|
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
|
||||||
1: '협상생성',
|
[SessionStatus.CREATED]: '협상생성',
|
||||||
2: '협상중',
|
[SessionStatus.IN_PROGRESS]: '협상중',
|
||||||
3: '협상완료',
|
[SessionStatus.DONE]: '협상완료',
|
||||||
4: '미참여',
|
[SessionStatus.NOT_PARTICIPATED]: '미참여',
|
||||||
5: '협상거부',
|
[SessionStatus.REJECTED]: '협상거부',
|
||||||
};
|
};
|
||||||
// 코드→라벨 단일 진입점. 미정의 코드는 코드 문자열 그대로.
|
// 코드→라벨 단일 진입점. 미정의 코드는 코드 문자열 그대로.
|
||||||
export const sessionStatusLabel = (code?: number | null): string =>
|
export const sessionStatusLabel = (code?: number | null): string =>
|
||||||
(code != null ? SESSION_STATUS_LABEL[code] : undefined) ?? String(code ?? '');
|
(code != null ? SESSION_STATUS_LABEL[code as SessionStatus] : undefined) ?? String(code ?? '');
|
||||||
const DELIVERY_TYPE_LABEL: Record<number, string> = { 1: '협력사배송', 2: '지정택배배송', 3: '픽업배송' };
|
|
||||||
|
|
||||||
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'.
|
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'.
|
||||||
export function fmtDateTime(s?: string | null): string {
|
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,
|
supplier_name: supplier?.name || sd.supplier_id,
|
||||||
item_id: sd.item_id,
|
item_id: sd.item_id,
|
||||||
item_name: product?.name || '부품',
|
item_name: product?.name || '부품',
|
||||||
status: SESSION_STATUS_LABEL[sd.status] || String(sd.status),
|
status: sd.status,
|
||||||
target_price: sd.target_price ?? 0,
|
target_price: sd.target_price ?? 0,
|
||||||
bid_price: sd.bid_price ?? null,
|
bid_price: sd.bid_price ?? null,
|
||||||
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
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,
|
session_card_id: c.session_card_id,
|
||||||
card_id: c.nego_card_id ?? c.wild_card_id ?? null,
|
card_id: c.nego_card_id ?? c.wild_card_id ?? null,
|
||||||
card_name: c.name || '-',
|
card_name: c.name || '-',
|
||||||
type: c.type === 2 ? '와일드 카드' : '협상 카드',
|
type: c.type === CardType.WILD ? '와일드 카드' : '협상 카드',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
negodata/front/src/lib/enumLabels.ts
Normal file
16
negodata/front/src/lib/enumLabels.ts
Normal 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]: '관리자',
|
||||||
|
};
|
||||||
@ -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 };
|
|
||||||
}
|
|
||||||
50
negodata/front/src/lib/useOverlayRouter.ts
Normal file
50
negodata/front/src/lib/useOverlayRouter.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -1,13 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
// 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처.
|
// 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처.
|
||||||
// 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로
|
// 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로
|
||||||
// 쿼리 파라미터를 만들어 수행한다 — 이 훅은 패칭을 하지 않고 상태만 관리한다.
|
|
||||||
//
|
|
||||||
// - search: 입력 즉시 반영(controlled) + debouncedSearch(쿼리용, 기본 300ms)로 분리해
|
|
||||||
// 키 입력마다 서버를 때리지 않는다.
|
|
||||||
// - filters: 임의 키-값(category/priority 등). 'ALL' 같은 "전체" 값의 의미는
|
|
||||||
// 호출부가 파라미터를 만들 때 결정한다(여기선 단순 보관).
|
|
||||||
// - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지).
|
// - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지).
|
||||||
export type ServerListControls = {
|
export type ServerListControls = {
|
||||||
page: number;
|
page: number;
|
||||||
@ -15,6 +9,7 @@ export type ServerListControls = {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
search: string; // input value (controlled)
|
search: string; // input value (controlled)
|
||||||
setSearch: (v: string) => void;
|
setSearch: (v: string) => void;
|
||||||
|
submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사)
|
||||||
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
|
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
|
||||||
filters: Record<string, string>;
|
filters: Record<string, string>;
|
||||||
setFilter: (key: string, value: string) => void;
|
setFilter: (key: string, value: string) => void;
|
||||||
@ -25,25 +20,39 @@ export function useServerList(opts?: {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
initialFilters?: Record<string, string>;
|
initialFilters?: Record<string, string>;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
|
minSearchLength?: number;
|
||||||
}): ServerListControls {
|
}): ServerListControls {
|
||||||
const pageSize = opts?.pageSize ?? 10;
|
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 [page, setPage] = useState(1);
|
||||||
const [search, setSearchInput] = useState('');
|
const [search, setSearchInput] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(() => opts?.initialFilters ?? {});
|
const [filters, setFilters] = useState<Record<string, string>>(() => opts?.initialFilters ?? {});
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
// 입력 디바운스 → 쿼리용 검색어
|
// 입력 디바운스 → 쿼리용 검색어.
|
||||||
|
// 최소 길이 미만은 빈 검색(전체)으로 둬서 1글자 스캔 요청이 서버로 나가지 않게 막는다.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), debounceMs);
|
timerRef.current = setTimeout(() => {
|
||||||
return () => clearTimeout(t);
|
const q = search.trim();
|
||||||
}, [search, debounceMs]);
|
setDebouncedSearch(q.length >= minSearchLength ? q : '');
|
||||||
|
}, debounceMs);
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [search, debounceMs, minSearchLength]);
|
||||||
|
|
||||||
const setSearch = (v: string) => {
|
const setSearch = (v: string) => {
|
||||||
setSearchInput(v);
|
setSearchInput(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 엔터: 대기 중인 디바운스 타이머를 버리고 최소길이 무시하고 즉시 1회 발사(의도적 검색).
|
||||||
|
const submitSearch = () => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
setDebouncedSearch(search.trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
const setFilter = (key: string, value: string) => {
|
const setFilter = (key: string, value: string) => {
|
||||||
setFilters((f) => ({ ...f, [key]: value }));
|
setFilters((f) => ({ ...f, [key]: value }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@ -51,5 +60,5 @@ export function useServerList(opts?: {
|
|||||||
|
|
||||||
const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize));
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,43 @@
|
|||||||
import { Plus, BookOpen } from 'lucide-react';
|
import { Plus, BookOpen } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
import { SearchInput } from '@/components/layout/PageToolbar';
|
import { SearchInput } from '@/components/layout/PageToolbar';
|
||||||
import { TablePagination } from '@/components/ui/table-pagination';
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { useClientPagination } from '@/lib/useClientPagination';
|
import { useServerList } from '@/lib/useServerList';
|
||||||
import { useCards } from '@/features/cards/hooks/useCards';
|
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 { CardTable } from '@/features/cards/components/CardTable';
|
||||||
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
|
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() {
|
export default function CardsPage() {
|
||||||
const { cards, createCard, updateCard, deleteCard } = useCards();
|
// 검색/탭/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
|
||||||
const { search, setSearch, activeTab, setActiveTab, filtered, counts } = useCardFilters(cards);
|
const list = useServerList({ pageSize: 10, initialFilters: { tab: 'ALL' } });
|
||||||
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
|
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> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
|
||||||
const overlay = useOverlayParams(['new', 'edit']);
|
const overlay = useOverlayRouter(['new', 'detail']);
|
||||||
const editId = overlay.get('edit');
|
const editId = overlay.get('detail');
|
||||||
const editing = editId ? cards.find((c) => c.id === editId) ?? null : null;
|
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 formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||||
const isFormOpen = overlay.has('new') || !!editing;
|
const isFormOpen = overlay.has('new') || !!editing;
|
||||||
|
|
||||||
const openCreate = () => overlay.open('new');
|
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) => {
|
const handleDeleteCard = async (id: string, cardName: string) => {
|
||||||
if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
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 }[] = [
|
const tabs: { id: CardTab; label: string; count: number }[] = [
|
||||||
{ id: 'ALL', label: '전체', count: counts.all },
|
{ id: 'ALL', label: '전체', count: totalNego + totalWild },
|
||||||
{ id: 'CARD', label: '협상카드', count: counts.card },
|
{ id: 'CARD', label: '협상카드', count: totalNego },
|
||||||
{ id: 'WILD', label: '와일드카드', count: counts.wild },
|
{ id: 'WILD', label: '와일드카드', count: totalWild },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -76,7 +86,7 @@ export default function CardsPage() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
id={`card-tab-${tab.id.toLowerCase()}`}
|
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 ${
|
className={`py-2 px-6 font-bold text-xs tracking-tight border-b-2 transition-all cursor-pointer ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
@ -91,21 +101,22 @@ export default function CardsPage() {
|
|||||||
{/* Filtering Search Bar */}
|
{/* Filtering Search Bar */}
|
||||||
<SearchInput
|
<SearchInput
|
||||||
id="card-search"
|
id="card-search"
|
||||||
value={search}
|
value={list.search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
placeholder="전체 카드이름, 카드번호, 코드 및 핵심멘트 검색..."
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
placeholder="전체 카드이름, 카드번호, 코드 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={pageItems}
|
data={cards}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
page={page}
|
page={list.page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalCount={totalCount}
|
totalCount={total}
|
||||||
pageSize={pageSize}
|
pageSize={list.pageSize}
|
||||||
onPageChange={setPage}
|
onPageChange={list.setPage}
|
||||||
label="전체 카드"
|
label="전체 카드"
|
||||||
unit="개"
|
unit="개"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Plus, Upload } from 'lucide-react';
|
import { Plus, Upload } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
@ -30,16 +30,16 @@ export default function PartnersPage() {
|
|||||||
const totalPages = list.totalPages(total);
|
const totalPages = list.totalPages(total);
|
||||||
|
|
||||||
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
||||||
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
// ?detail=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
||||||
const overlay = useOverlayParams(['new', 'edit', 'modal']);
|
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
|
||||||
const editId = overlay.get('edit');
|
const editId = overlay.get('detail');
|
||||||
const modal = overlay.get('modal'); // 'excel' | null
|
const modal = overlay.get('modal'); // 'excel' | null
|
||||||
const editing = editId ? allPartners.find((p) => p.supplier_id === editId) ?? null : null;
|
const editing = editId ? allPartners.find((p) => p.supplier_id === editId) ?? null : null;
|
||||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||||
const isFormOpen = overlay.has('new') || !!editing;
|
const isFormOpen = overlay.has('new') || !!editing;
|
||||||
|
|
||||||
const openCreate = () => overlay.open('new');
|
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) => {
|
const handleDeletePartner = async (id: string, partnerName: string) => {
|
||||||
if (await confirm({ title: '협력사 삭제', description: `[${partnerName}] 파트너사를 협력사 목록에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
if (await confirm({ title: '협력사 삭제', description: `[${partnerName}] 파트너사를 협력사 목록에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
||||||
@ -73,6 +73,7 @@ export default function PartnersPage() {
|
|||||||
id="partner-search"
|
id="partner-search"
|
||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Upload, TrendingDown } from 'lucide-react';
|
import { Plus, Upload, TrendingDown } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
@ -45,17 +45,16 @@ export default function ProductsPage() {
|
|||||||
// 행 선택(테이블 체크박스 + 최저가 모달 대상)
|
// 행 선택(테이블 체크박스 + 최저가 모달 대상)
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// 오버레이(폼/최저가/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
// 딥링크·뒤로가기·새로고침 지원
|
||||||
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
|
||||||
const overlay = useOverlayParams(['new', 'edit', 'modal']);
|
const editId = overlay.get('detail');
|
||||||
const editId = overlay.get('edit');
|
|
||||||
const modal = overlay.get('modal'); // 'price' | 'excel' | null
|
const modal = overlay.get('modal'); // 'price' | 'excel' | null
|
||||||
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
|
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
|
||||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||||
const isFormOpen = overlay.has('new') || !!editing;
|
const isFormOpen = overlay.has('new') || !!editing;
|
||||||
|
|
||||||
const openCreate = () => overlay.open('new');
|
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) => {
|
const handleDeleteProduct = async (id: string, prodName: string) => {
|
||||||
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
||||||
@ -99,6 +98,7 @@ export default function ProductsPage() {
|
|||||||
id="product-search"
|
id="product-search"
|
||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
placeholder="상품명 또는 상품 코드로 통합 검색..."
|
placeholder="상품명 또는 상품 코드로 통합 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +1,56 @@
|
|||||||
import { Settings, Plus } from 'lucide-react';
|
import { Settings, Plus } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
||||||
import { TablePagination } from '@/components/ui/table-pagination';
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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 { 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 { QuotationTable } from '@/features/quotations/components/QuotationTable';
|
||||||
import { QuotationDetailDrawer } from '@/features/quotations/components/QuotationDetailDrawer';
|
import { QuotationDetailSheet } from '@/features/quotations/components/QuotationDetailSheet';
|
||||||
import { CreateQuotationWizard } from '@/features/quotations/components/CreateQuotationWizard';
|
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
|
||||||
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
|
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() {
|
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 {
|
const {
|
||||||
products,
|
products,
|
||||||
partners,
|
partners,
|
||||||
cards,
|
cards,
|
||||||
quotations,
|
quotations,
|
||||||
|
total,
|
||||||
quotationSettings,
|
quotationSettings,
|
||||||
stopNegotiation,
|
closeQuotation,
|
||||||
addSetting,
|
addSetting,
|
||||||
deleteSetting,
|
deleteSetting,
|
||||||
createQuotation,
|
createQuotation,
|
||||||
} = useQuotations();
|
} = useQuotations(params);
|
||||||
|
const totalPages = list.totalPages(total);
|
||||||
|
|
||||||
const { search, setSearch, statusFilter, setStatusFilter, typeFilter, setTypeFilter, filtered } =
|
const overlay = useOverlayRouter(['detail', 'create', 'settings']);
|
||||||
useQuotationFilters(quotations);
|
|
||||||
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
|
|
||||||
|
|
||||||
// 오버레이(상세/생성/세팅)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
|
||||||
// ?detail=<id> 로 직접 접근하면 데이터 로드 후 상세가 자동으로 열린다.
|
|
||||||
const overlay = useOverlayParams(['detail', 'create', 'settings']);
|
|
||||||
const detailId = overlay.get('detail');
|
const detailId = overlay.get('detail');
|
||||||
const isCreateOpen = overlay.has('create');
|
const isCreateOpen = overlay.has('create');
|
||||||
const isSettingsOpen = overlay.has('settings');
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@ -56,60 +69,72 @@ export default function QuotationPage() {
|
|||||||
<button
|
<button
|
||||||
id="quotation-create-btn"
|
id="quotation-create-btn"
|
||||||
onClick={() => overlay.open('create')}
|
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} />
|
<Plus size={15} />
|
||||||
<span>신규 협상견적 등록</span>
|
<span>신규 견적 등록</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
id="quotation-search"
|
id="quotation-search"
|
||||||
value={search}
|
value={list.search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
placeholder="견적명 또는 견적 번호로 실시간 서치..."
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
placeholder="견적명 또는 견적 번호로 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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">
|
<SelectTrigger id="quotation-status-filter" className="font-bold">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{(value) =>
|
||||||
|
value === 'ALL'
|
||||||
|
? '전체 견적상태'
|
||||||
|
: statusOptions.find((o) => String(o.value) === value)?.label ?? ''
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">전체 견적상태</SelectItem>
|
<SelectItem value="ALL">전체 견적상태</SelectItem>
|
||||||
{QUOTATION_STATUS_FILTERS.map((s) => (
|
{statusOptions.map((o) => (
|
||||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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">
|
<SelectTrigger id="quotation-type-filter">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')}
|
{(value) =>
|
||||||
|
value === 'ALL'
|
||||||
|
? '전체 유형'
|
||||||
|
: typeOptions.find((o) => String(o.value) === value)?.label ?? ''
|
||||||
|
}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">전체 유형</SelectItem>
|
<SelectItem value="ALL">전체 유형</SelectItem>
|
||||||
<SelectItem value="RE_NEGOTIATION">재협상</SelectItem>
|
{typeOptions.map((o) => (
|
||||||
<SelectItem value="RE_ESTIMATE">재견적</SelectItem>
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<QuotationTable
|
<QuotationTable
|
||||||
data={pageItems}
|
data={quotations}
|
||||||
products={products}
|
products={products}
|
||||||
onOpenDetail={(id) => overlay.open('detail', id)}
|
onOpenDetail={(id) => overlay.open('detail', id)}
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
page={page}
|
page={list.page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalCount={totalCount}
|
totalCount={total}
|
||||||
pageSize={pageSize}
|
pageSize={list.pageSize}
|
||||||
onPageChange={setPage}
|
onPageChange={list.setPage}
|
||||||
label="전체 견적"
|
label="전체 견적"
|
||||||
unit="건"
|
unit="건"
|
||||||
/>
|
/>
|
||||||
@ -117,19 +142,16 @@ export default function QuotationPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{activeQuotation && (
|
{activeQuotation && (
|
||||||
<QuotationDetailDrawer
|
<QuotationDetailSheet
|
||||||
key={activeQuotation.id}
|
key={activeQuotation.qt_id}
|
||||||
estimate={activeQuotation}
|
quotation={activeQuotation}
|
||||||
products={products}
|
onCloseQuotation={closeQuotation}
|
||||||
partners={partners}
|
|
||||||
quotationSettings={quotationSettings}
|
|
||||||
onStop={stopNegotiation}
|
|
||||||
onClose={overlay.close}
|
onClose={overlay.close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCreateOpen && (
|
{isCreateOpen && (
|
||||||
<CreateQuotationWizard
|
<QuotationCreateModal
|
||||||
open
|
open
|
||||||
products={products}
|
products={products}
|
||||||
partners={partners}
|
partners={partners}
|
||||||
|
|||||||
@ -1,306 +1,35 @@
|
|||||||
// ============================================================
|
// 2개 이상 feature가 공유하는 타입만 둔다. 단일 feature 전용은 그 feature/types.ts 로.
|
||||||
// Negosium/NegoData ERD Type Definitions (TypeScript)
|
import type { ItemData } from '@/api/generated/model/itemData';
|
||||||
// ------------------------------------------------------------
|
import type { SupplierData } from '@/api/generated/model/supplierData';
|
||||||
// 기준: 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 없음" 으로 표시한다.
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
// Base abstract structure properties
|
export type Product = ItemData & {
|
||||||
interface BaseEntity {
|
deleted?: boolean;
|
||||||
created_at: string;
|
id?: string;
|
||||||
updated_at: string;
|
minPrice?: number;
|
||||||
deleted: boolean;
|
status?: string;
|
||||||
}
|
|
||||||
|
|
||||||
// 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 Partner = Partial<Supplier> & {
|
export type Partner = SupplierData & {
|
||||||
id?: string; // supplier_id back-compatibility map
|
deleted?: boolean;
|
||||||
managerName?: string; // manager_name
|
id?: string;
|
||||||
managerEmail?: string; // manager_email
|
managerName?: string;
|
||||||
managerPhone?: string; // manager_contact_number
|
managerEmail?: string;
|
||||||
manager_contact_number?: string; // DB 컬럼 직접 매핑(철자 정상)
|
managerPhone?: string;
|
||||||
rank?: 'A' | 'B' | 'C' | 'S'; // computed from priority
|
rank?: 'A' | 'B' | 'C' | 'S';
|
||||||
status?: string; // mapped to deleted
|
status?: string;
|
||||||
memo?: string; // back-compatible details
|
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 {
|
export interface NegotiationCard {
|
||||||
id: string; // nego_card_id or wild_card_id
|
id: string;
|
||||||
isWildcard: boolean; // mapping based on source table
|
isWildcard: boolean;
|
||||||
code: string; // number (식별번호) or custom code
|
code: string;
|
||||||
title: string; // name
|
title: string;
|
||||||
scriptPreview: string; // script
|
scriptPreview: string;
|
||||||
editorScript: any; // edit_script (JSON)
|
editorScript: any;
|
||||||
status: 'ACTIVE' | 'INACTIVE';
|
status: 'ACTIVE' | 'INACTIVE';
|
||||||
triggerCondition?: string; // wild_card's condition
|
triggerCondition?: string;
|
||||||
memo?: string; // wild_card's memo
|
memo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS';
|
export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user