diff --git a/backend/config/config.local.toml.example b/backend/config/config.local.toml.example index 06082bd..93dfe89 100644 --- a/backend/config/config.local.toml.example +++ b/backend/config/config.local.toml.example @@ -37,3 +37,9 @@ access_key = "" refresh_key = "" access_expire_min = 30 refresh_expire_day = 7 + +# 협상 agent(포트 9500) 접속. use_mock=true 면 agent 미연동 — 내장 mock 응답 사용(통합 테스트/로컬 기본). +[AgentConfig] +base_url = "http://127.0.0.1:9500" +timeout_sec = 10.0 +use_mock = true diff --git a/docker-compose.yml b/docker-compose.yml index 8126521..b5d45c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,10 @@ services: environment: APP_ENV: local DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override) + RELOAD: "1" # uvicorn --reload 활성 → 소스 저장 시 자동 재기동(재빌드 불필요) + SCHEDULER_ENABLED: "1" # 마감 크론 활성(단일 워커라 중복 없음). 운영 다중 워커면 1개 프로세스에서만 1 + volumes: + - ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨 ports: - "9400:9400" extra_hosts: diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index abfb8e9..7c4d97c 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -3,6 +3,17 @@ from enum import Enum, auto from fastapi import HTTPException +class CodeEnum(Enum): + """OpenAPI 스키마에 x-enum-varnames(멤버 이름)을 실어 orval 이 이름 있는 enum 을 생성하게 하는 베이스.""" + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler): + json_schema = handler(core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + json_schema["x-enum-varnames"] = [m.name for m in cls] + return json_schema + + class ErrorType(Enum): """서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다. HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다. @@ -84,35 +95,35 @@ class DBWRType(Enum): # 도메인 코드값 -class UserStatus(Enum): +class UserStatus(CodeEnum): """users.status 코드값.""" ACTIVE = 1 INACTIVE = 2 -class UserRole(Enum): +class UserRole(CodeEnum): """users.role 코드값.""" USER = 1 MANAGER = 2 -class CompanyStatus(Enum): +class CompanyStatus(CodeEnum): """companies.status 코드값.""" ACTIVE = 1 INACTIVE = 2 -class QuotationType(Enum): +class QuotationType(CodeEnum): """quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N).""" RENEGO = 1 REQUOTE = 2 -class QuotationStatus(Enum): +class QuotationStatus(CodeEnum): """quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다.""" CREATED = 1 @@ -121,7 +132,7 @@ class QuotationStatus(Enum): ON_HOLD = 4 -class SessionStatus(Enum): +class SessionStatus(CodeEnum): """negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태.""" CREATED = 1 @@ -131,14 +142,14 @@ class SessionStatus(Enum): REJECTED = 5 -class ChatSender(Enum): +class ChatSender(CodeEnum): """negotiation.chats.sender 코드값. 채팅 발신 주체.""" BOT = 1 USER = 2 -class DeliveryType(Enum): +class DeliveryType(CodeEnum): """items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합.""" PARTNER = 1 # 협력사배송 @@ -146,50 +157,15 @@ class DeliveryType(Enum): PICKUP = 3 # 픽업배송 -class CardStatus(Enum): +class CardStatus(CodeEnum): """nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE.""" ACTIVE = 1 INACTIVE = 2 -# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드). -ENUM_LABELS = { - UserStatus.ACTIVE: "활성", - UserStatus.INACTIVE: "비활성", - UserRole.USER: "일반", - UserRole.MANAGER: "관리자", - CompanyStatus.ACTIVE: "활성", - CompanyStatus.INACTIVE: "비활성", - QuotationType.RENEGO: "재협상", - QuotationType.REQUOTE: "재견적", - QuotationStatus.CREATED: "견적생성", - QuotationStatus.ACTIVE: "견적진행중", - QuotationStatus.CLOSED: "견적마감", - QuotationStatus.ON_HOLD: "협상보류", - SessionStatus.CREATED: "협상생성", - SessionStatus.IN_PROGRESS: "협상중", - SessionStatus.DONE: "협상완료", - SessionStatus.NOT_PARTICIPATED: "미참여", - SessionStatus.REJECTED: "협상거부", - ChatSender.BOT: "봇", - ChatSender.USER: "협력사", - DeliveryType.PARTNER: "협력사배송", - DeliveryType.COURIER: "지정택배배송", - DeliveryType.PICKUP: "픽업배송", - CardStatus.ACTIVE: "적용", - CardStatus.INACTIVE: "대기", -} +class CardType(CodeEnum): + """negotiation.chats.card_type / quotation_cards.type 코드값. 1=nego_card, 2=wild_card.""" -# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다. -DOMAIN_ENUMS = { - "user_status": UserStatus, - "user_role": UserRole, - "company_status": CompanyStatus, - "quotation_type": QuotationType, - "quotation_status": QuotationStatus, - "session_status": SessionStatus, - "chat_sender": ChatSender, - "delivery_type": DeliveryType, - "card_status": CardStatus, -} + NEGO = 1 + WILD = 2 diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 0bfb93f..a22f69a 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -2,15 +2,15 @@ from abc import ABC, abstractmethod from datetime import datetime from typing import Optional, Tuple -from sqlalchemy import select, func, and_, update +from sqlalchemy import select, func, and_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import ( - quotations, sessions, chats, nego_cards, wild_cards, items, quotation_settings, + quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings, version_nego_cards, version_wild_cards, ) -from common.enums import ErrorType +from common.enums import ErrorType, QuotationStatus, QuotationType, SessionStatus from common.logger import LOG from common.utils.gtime import GTime @@ -19,7 +19,7 @@ from common.utils.gtime import GTime class IQuotationCRUD(ABC): @abstractmethod async def search( - self, cdb: AsyncSession, status, type_, start_from, start_to, skip, limit + self, cdb: AsyncSession, search, status, type_, start_from, start_to, skip, limit ) -> Tuple[ErrorType, list, int]: pass @@ -59,6 +59,10 @@ class IQuotationCRUD(ABC): async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType: pass + @abstractmethod + async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: + pass + @abstractmethod async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: pass @@ -83,11 +87,33 @@ class IQuotationCRUD(ABC): async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: pass + # ----- 스케줄러(크론) 전용 ----- + @abstractmethod + async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def list_requote_done(self, cdb: AsyncSession) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def list_done_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def bulk_update_quotation_status(self, cdb: AsyncSession, qt_ids, status: int) -> ErrorType: + pass + + @abstractmethod + async def bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType: + pass + class QuotationCRUD(IQuotationCRUD): async def search( self, cdb: AsyncSession, + search: Optional[str], status: Optional[str], type_: Optional[str], start_from: Optional[datetime], @@ -97,10 +123,12 @@ class QuotationCRUD(IQuotationCRUD): ) -> Tuple[ErrorType, list, int]: try: conditions = [quotations.deleted == False] # noqa: E712 + if search: + conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%"))) if status: - conditions.append(quotations.status == status) + conditions.append(quotations.status == int(status)) # status/type 는 SMALLINT 코드 — 문자열 쿼리값을 정수로 if type_: - conditions.append(quotations.type == type_) + conditions.append(quotations.type == int(type_)) if start_from: conditions.append(quotations.start_time >= start_from) if start_to: @@ -308,6 +336,23 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: + # 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다. + try: + query = ( + update(sessions) + .where( + sessions.quotation_id == qt_id, + sessions.status.in_(from_statuses), + sessions.deleted == False, # noqa: E712 + ) + .values(status=to_status, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: try: query = update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=GTime.UTC()) @@ -316,6 +361,106 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + # ----- 스케줄러(크론) 전용 ----- + async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]: + """[잡①] 마감시각이 지났는데 아직 안 닫힌 견적 qt_id 목록. + 조건: end_time < now AND status != 견적마감 AND not deleted.""" + try: + query = select(quotations.qt_id).where( + quotations.end_time < now, + quotations.status != QuotationStatus.CLOSED.value, + quotations.deleted == False, # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def list_requote_done(self, cdb: AsyncSession) -> Tuple[ErrorType, list]: + """[잡②] 재견적(REQUOTE) 중 협상완료(DONE) 세션이 1건 이상이고 아직 안 닫힌 견적 qt_id 목록. + 재견적은 세션이 독립적이라 하나라도 완료되면 나머지를 기다리지 않고 마감 대상.""" + try: + done_exists = ( + select(sessions.session_id) + .where( + sessions.quotation_id == quotations.qt_id, + sessions.status == SessionStatus.DONE.value, + sessions.deleted == False, # noqa: E712 + ) + .exists() + ) + query = select(quotations.qt_id).where( + quotations.type == QuotationType.REQUOTE.value, + quotations.status != QuotationStatus.CLOSED.value, + quotations.deleted == False, # noqa: E712 + done_exists, + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def list_done_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: + """[잡②] 견적의 협상완료(DONE) 세션 → (supplier_id, bid_price, supplier_name) 목록. 낙찰자 판정 입력.""" + try: + query = ( + select(sessions.supplier_id, sessions.bid_price, suppliers.name) + .join(suppliers, suppliers.supplier_id == sessions.supplier_id) + .where( + sessions.quotation_id == qt_id, + sessions.status == SessionStatus.DONE.value, + sessions.deleted == False, # noqa: E712 + suppliers.deleted == False, # noqa: E712 + ) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def bulk_update_quotation_status(self, cdb: AsyncSession, qt_ids, status: int) -> ErrorType: + """[잡①] 여러 견적의 status 를 한 번에 전이.""" + try: + if not qt_ids: + return ErrorType.SUCCESS + query = ( + update(quotations) + .where(quotations.qt_id.in_(qt_ids)) + .values(status=status, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType: + """[잡①] 여러 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외).""" + try: + if not qt_ids: + return ErrorType.SUCCESS + query = ( + update(sessions) + .where( + sessions.quotation_id.in_(qt_ids), + sessions.status.in_(from_statuses), + sessions.deleted == False, # noqa: E712 + ) + .values(status=to_status, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + # ----- 견적 상세: 세션 / 채팅 / 사용카드 (읽기 전용) ----- async def list_sessions(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: try: diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index bfe5743..1d50362 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -10,3 +10,4 @@ pydantic>=2.0 python-multipart openpyxl httpx +apscheduler>=3.10 diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 19ce6b0..035373e 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -9,22 +9,24 @@ from common.database.db_session_manager import DB_SESSION_MNG from common.logger import LOG from common.utils.gtime import GTime from config.server_configs import web_server_config +from scheduler import shutdown_scheduler, start_scheduler import router.v1.auth.account import router.v1.item.item import router.v1.supplier.supplier import router.v1.card.card import router.v1.quotation.quotation import router.v1.quotation_setting.quotation_setting -import router.v1.enums.enums API_SERVER_START_TIME = GTime.UTCStr() @asynccontextmanager async def lifespan(app: FastAPI): - # startup + # startup: 마감 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만) + start_scheduler() yield - # shutdown: DB 엔진 커넥션 풀 정리 + # shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리 + shutdown_scheduler() await DB_SESSION_MNG.dispose_all() @@ -64,4 +66,3 @@ app.include_router(router.v1.supplier.supplier.router) app.include_router(router.v1.card.card.router) app.include_router(router.v1.quotation.quotation.router) app.include_router(router.v1.quotation_setting.quotation_setting.router) -app.include_router(router.v1.enums.enums.router) diff --git a/negodata/backend/router/v1/auth/protocol.py b/negodata/backend/router/v1/auth/protocol.py index 983ed8d..f07c0d0 100644 --- a/negodata/backend/router/v1/auth/protocol.py +++ b/negodata/backend/router/v1/auth/protocol.py @@ -52,6 +52,5 @@ class Res_Me(Res_WebPacketProtocol): name: Optional[str] = None email: Optional[str] = None contact_number: Optional[str] = None - role: int = UserRole.USER.value - role_label: str = "" + role: UserRole = UserRole.USER company: Optional[CompanyData] = Field(default=None) diff --git a/negodata/backend/router/v1/card/card.py b/negodata/backend/router/v1/card/card.py index 8281109..e233947 100644 --- a/negodata/backend/router/v1/card/card.py +++ b/negodata/backend/router/v1/card/card.py @@ -21,9 +21,10 @@ async def list_cards( service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"), + is_wildcard: bool | None = Query(None, description="탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드"), pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, pg)) + return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, is_wildcard, pg)) @router.post(path="/create", response_model=Res_Card, summary="협상카드 등록") diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index c8bec4b..2bf3e19 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -44,7 +44,7 @@ class CardData(WebPacketProtocol): number: Optional[str] = None script: Optional[str] = None edit_script: Optional[Any] = None - status: int = CardStatus.ACTIVE.value + status: CardStatus = CardStatus.ACTIVE condition: Optional[str] = None memo: Optional[str] = None created_at: Optional[datetime] = None @@ -57,6 +57,8 @@ class Res_Card(Res_WebPacketProtocol): class Res_CardList(Res_PageProtocol): cards: list[CardData] = [] + total_nego: int = 0 # 협상카드 탭 카운트(검색 필터 반영) + total_wild: int = 0 # 와일드카드 탭 카운트(검색 필터 반영) class Res_DeleteCard(Res_WebPacketProtocol): diff --git a/negodata/backend/router/v1/enums/enums.py b/negodata/backend/router/v1/enums/enums.py deleted file mode 100644 index 07b45bf..0000000 --- a/negodata/backend/router/v1/enums/enums.py +++ /dev/null @@ -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) diff --git a/negodata/backend/router/v1/enums/protocol.py b/negodata/backend/router/v1/enums/protocol.py deleted file mode 100644 index 044351f..0000000 --- a/negodata/backend/router/v1/enums/protocol.py +++ /dev/null @@ -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]] = {} diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index dc973a9..1ef449c 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import ConfigDict +from common.enums import DeliveryType from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol @@ -71,7 +72,7 @@ class ItemData(WebPacketProtocol): moq: Optional[str] = None lead_time: Optional[int] = None quantity_unit: Optional[str] = None - delivery_type: Optional[int] = None + delivery_type: Optional[DeliveryType] = None vat_yn: Optional[bool] = None delivery_fee_yn: Optional[bool] = None created_at: Optional[datetime] = None diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 3c53e0d..d72172f 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -4,6 +4,7 @@ from typing import Any, Optional from pydantic import ConfigDict +from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol @@ -39,9 +40,9 @@ class QuotationData(WebPacketProtocol): version_id: uuid.UUID name: str number: str - type: int + type: QuotationType round: int = 1 - status: int + status: QuotationStatus start_time: datetime end_time: datetime manager_name: Optional[str] = None @@ -82,15 +83,15 @@ class SessionData(WebPacketProtocol): item_id: uuid.UUID qt_number: str qt_round: int - qt_type: int + qt_type: QuotationType target_price: int - status: int + status: SessionStatus bid_price: Optional[int] = None bid_at: Optional[datetime] = None end_time: datetime reject_reason: Optional[str] = None reject_price: Optional[int] = None - reject_delivery_type: Optional[int] = None + reject_delivery_type: Optional[DeliveryType] = None url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성 @@ -124,11 +125,13 @@ class ChatMessageData(WebPacketProtocol): session_id: uuid.UUID card_id: Optional[uuid.UUID] = None index: int - sender: int + sender: ChatSender target_price: int card_used_yn: Optional[bool] = None indicator_value: Optional[float] = None - card_type: Optional[int] = None + card_type: Optional[CardType] = None + script: Optional[str] = None + step: Optional[str] = None class Res_SessionChat(Res_WebPacketProtocol): @@ -150,7 +153,7 @@ class QuotationCardData(WebPacketProtocol): qt_id: Optional[uuid.UUID] = None nego_card_id: Optional[uuid.UUID] = None wild_card_id: Optional[uuid.UUID] = None - type: Optional[int] = None + type: Optional[CardType] = None number: Optional[str] = None name: Optional[str] = None script: Optional[str] = None # 협상 멘트(평문) diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index 5de3905..639d923 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -31,13 +31,14 @@ router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: { async def list_quotations( service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), + search: str | None = Query(None, description="견적명/견적번호 검색"), status: str | None = Query(None, description="상태 필터(정확히 일치)"), type: str | None = Query(None, description="유형 필터(정확히 일치)"), start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"), start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"), pg: PageParams = Depends(), ): - return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, pg)) + return RemoveNoneResponse(await service.list_quotations(search, status, type, start_from, start_to, pg)) @router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성") diff --git a/negodata/backend/scheduler/__init__.py b/negodata/backend/scheduler/__init__.py new file mode 100644 index 0000000..9955173 --- /dev/null +++ b/negodata/backend/scheduler/__init__.py @@ -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") diff --git a/negodata/backend/scheduler/jobs.py b/negodata/backend/scheduler/jobs.py new file mode 100644 index 0000000..c320a11 --- /dev/null +++ b/negodata/backend/scheduler/jobs.py @@ -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 diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py index 32536a1..d3c39af 100644 --- a/negodata/backend/services/auth_service.py +++ b/negodata/backend/services/auth_service.py @@ -4,7 +4,7 @@ from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import users -from common.enums import DBWRType, ErrorType, UserStatus, UserRole, ENUM_LABELS +from common.enums import DBWRType, ErrorType, UserStatus, UserRole from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD @@ -156,7 +156,6 @@ class AuthService: res.email = user.email res.contact_number = user.contact_number res.role = user.role - res.role_label = ENUM_LABELS.get(UserRole(user.role), str(user.role)) res.company = company return res diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index 222f370..2f4ec2b 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -79,18 +79,23 @@ class CardService: return ErrorType.CARD_NOT_FOUND, None, None, None, False # ---- 목록 ---------------------------------------------------------------- - async def list_cards(self, user_id: str, search, pg: PageParams) -> Res_CardList: + async def list_cards(self, user_id: str, search, is_wildcard, pg: PageParams) -> Res_CardList: + """is_wildcard: None=전체(두 테이블 머지) / False=협상카드만 / True=와일드카드만. + 탭이 무엇이든 양쪽 카운트(total_nego/total_wild)는 항상 채운다(검색 필터 반영). + 선택 안 된 탭은 limit=0 으로 카운트만 받아 행은 가져오지 않는다.""" res = Res_CardList(page=pg.page, size=pg.size) if not user_id: return res user_uuid = uuid.UUID(user_id) # 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분). fetch = pg.skip + pg.size + nego_limit = 0 if is_wildcard is True else fetch + wild_limit = 0 if is_wildcard is False else fetch err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda( nego_cards.DBType(), DBWRType.DB_READ.value, - lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, fetch), + lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, nego_limit), ) if err_n != ErrorType.SUCCESS: res.result.SetResult(err_n) @@ -99,7 +104,7 @@ class CardService: err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda( wild_cards.DBType(), DBWRType.DB_READ.value, - lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, fetch), + lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, wild_limit), ) if err_w != ErrorType.SUCCESS: res.result.SetResult(err_w) @@ -108,7 +113,15 @@ class CardService: merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows] merged.sort(key=lambda c: c.created_at or "", reverse=True) res.cards = merged[pg.skip : pg.skip + pg.size] - res.total = total_n + total_w + res.total_nego = total_n + res.total_wild = total_w + # 선택된 탭 기준 페이지네이션 총건수(전체=합산). + if is_wildcard is True: + res.total = total_w + elif is_wildcard is False: + res.total = total_n + else: + res.total = total_n + total_w return res # ---- 단건 조회 ----------------------------------------------------------- diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 462166b..618d676 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -82,13 +82,13 @@ class QuotationService: return ErrorType.QUOTATION_NOT_FOUND, None return ErrorType.SUCCESS, quotation - async def list_quotations(self, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: + async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: res = Res_QuotationList(page=pg.page, size=pg.size) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: self.quotation_crud.search(s, status, type_, start_from, start_to, pg.skip, pg.size), + lambda s: self.quotation_crud.search(s, search, status, type_, start_from, start_to, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -274,10 +274,16 @@ class QuotationService: res.result.SetResult(err_type) return res - # 상태를 '견적마감'으로 변경(실제 DB 업데이트) + # 견적 '견적마감'(CLOSED) + 딸린 세션 정리를 한 트랜잭션으로. + # 세션은 아직 시작 전(협상생성)인 것만 미참여로 떨군다. 협상중/완료/거부/미참여는 그대로 둔다. err_type = await DB_SESSION_MNG.execute_lambda_run( [quotations.DBType()], - [lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value})], + [ + lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value}), + lambda s: self.quotation_crud.update_sessions_status( + s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value + ), + ], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -387,6 +393,7 @@ class QuotationService: return res # chats.seq → ChatMessageData.index 로 매핑. indicator_value(Decimal) → float. + # 말풍선 텍스트는 chats.meta.script 에 영속화돼 있어 그대로 꺼낸다(프론트 하드코딩 X). res.messages = [ ChatMessageData( chat_id=r.chat_id, @@ -398,6 +405,8 @@ class QuotationService: card_used_yn=r.card_used_yn, indicator_value=float(r.indicator_value) if r.indicator_value is not None else None, card_type=r.card_type, + script=(r.meta or {}).get("script"), + step=(r.meta or {}).get("step"), ) for r in rows ] diff --git a/negodata/backend/web_main.py b/negodata/backend/web_main.py index e312a77..b924af8 100644 --- a/negodata/backend/web_main.py +++ b/negodata/backend/web_main.py @@ -6,6 +6,8 @@ # 또는 uvicorn 직접 실행: # uvicorn router.router:app --reload --host=0.0.0.0 --port=9400 +import os + import uvicorn from common.logger import LOG @@ -21,21 +23,20 @@ if __name__ == "__main__": LOG.i(f"Server Port : {web_server_config.port}") LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}") - if web_server_config.is_ssl: - uvicorn.run( - "router.router:app", - host="0.0.0.0", - port=web_server_config.port, - access_log=False, - workers=web_server_config.process_count, - ssl_keyfile="./SSL/key.pem", - ssl_certfile="./SSL/cert.pem", - ) + # RELOAD=1 (개발 컨테이너) → 소스 변경 시 자동 재기동. reload 와 workers(다중) 는 함께 못 쓰므로 분기. + reload = os.environ.get("RELOAD") == "1" + + run_kwargs = dict( + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + ) + if reload: + run_kwargs["reload"] = True else: - uvicorn.run( - "router.router:app", - host="0.0.0.0", - port=web_server_config.port, - access_log=False, - workers=web_server_config.process_count, - ) + run_kwargs["workers"] = web_server_config.process_count + if web_server_config.is_ssl: + run_kwargs["ssl_keyfile"] = "./SSL/key.pem" + run_kwargs["ssl_certfile"] = "./SSL/cert.pem" + + uvicorn.run("router.router:app", **run_kwargs) diff --git a/negodata/front/src/api/generated/enums/enums.ts b/negodata/front/src/api/generated/enums/enums.ts deleted file mode 100644 index fb3e5ee..0000000 --- a/negodata/front/src/api/generated/enums/enums.ts +++ /dev/null @@ -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 unknown> = Parameters[1]; - - - -/** - * @summary 도메인 코드 enum 전체 - */ -export const listEnums = ( - - options?: SecondParameter,signal?: AbortSignal -) => { - - - return customFetch( - {url: `/v1/enums`, method: 'GET', signal - }, - options); - } - - - - -export const getListEnumsQueryKey = () => { - return [ - `/v1/enums` - ] as const; - } - - -export const getListEnumsQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListEnumsQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => listEnums(requestOptions, signal); - - - - - - return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } -} - -export type ListEnumsQueryResult = NonNullable>> -export type ListEnumsQueryError = void - - -export function useListEnums>, TError = void>( - options: { query:Partial>, TError, TData>> & Pick< - DefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useListEnums>, TError = void>( - options?: { query?:Partial>, TError, TData>> & Pick< - UndefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useListEnums>, TError = void>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -/** - * @summary 도메인 코드 enum 전체 - */ - -export function useListEnums>, TError = void>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getListEnumsQueryOptions(options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - query.queryKey = queryOptions.queryKey ; - - return query; -} - - - - diff --git a/negodata/front/src/api/generated/model/cardData.ts b/negodata/front/src/api/generated/model/cardData.ts index 797f7c0..c39fba8 100644 --- a/negodata/front/src/api/generated/model/cardData.ts +++ b/negodata/front/src/api/generated/model/cardData.ts @@ -9,6 +9,7 @@ import type { CardDataName } from './cardDataName'; import type { CardDataNumber } from './cardDataNumber'; import type { CardDataScript } from './cardDataScript'; import type { CardDataEditScript } from './cardDataEditScript'; +import type { CardStatus } from './cardStatus'; import type { CardDataCondition } from './cardDataCondition'; import type { CardDataMemo } from './cardDataMemo'; import type { CardDataCreatedAt } from './cardDataCreatedAt'; @@ -22,7 +23,7 @@ export interface CardData { number?: CardDataNumber; script?: CardDataScript; edit_script?: CardDataEditScript; - status?: number; + status?: CardStatus; condition?: CardDataCondition; memo?: CardDataMemo; created_at?: CardDataCreatedAt; diff --git a/negodata/front/src/api/generated/model/cardStatus.ts b/negodata/front/src/api/generated/model/cardStatus.ts new file mode 100644 index 0000000..3b1a68a --- /dev/null +++ b/negodata/front/src/api/generated/model/cardStatus.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/cardType.ts b/negodata/front/src/api/generated/model/cardType.ts new file mode 100644 index 0000000..2958edf --- /dev/null +++ b/negodata/front/src/api/generated/model/cardType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/chatMessageData.ts b/negodata/front/src/api/generated/model/chatMessageData.ts index 3b484ae..8183307 100644 --- a/negodata/front/src/api/generated/model/chatMessageData.ts +++ b/negodata/front/src/api/generated/model/chatMessageData.ts @@ -5,18 +5,23 @@ * OpenAPI spec version: 0.1.0 */ import type { ChatMessageDataCardId } from './chatMessageDataCardId'; +import type { ChatSender } from './chatSender'; import type { ChatMessageDataCardUsedYn } from './chatMessageDataCardUsedYn'; import type { ChatMessageDataIndicatorValue } from './chatMessageDataIndicatorValue'; import type { ChatMessageDataCardType } from './chatMessageDataCardType'; +import type { ChatMessageDataScript } from './chatMessageDataScript'; +import type { ChatMessageDataStep } from './chatMessageDataStep'; export interface ChatMessageData { chat_id: string; session_id: string; card_id?: ChatMessageDataCardId; index: number; - sender: number; + sender: ChatSender; target_price: number; card_used_yn?: ChatMessageDataCardUsedYn; indicator_value?: ChatMessageDataIndicatorValue; card_type?: ChatMessageDataCardType; + script?: ChatMessageDataScript; + step?: ChatMessageDataStep; } diff --git a/negodata/front/src/api/generated/model/chatMessageDataCardType.ts b/negodata/front/src/api/generated/model/chatMessageDataCardType.ts index 5cdb07f..d037726 100644 --- a/negodata/front/src/api/generated/model/chatMessageDataCardType.ts +++ b/negodata/front/src/api/generated/model/chatMessageDataCardType.ts @@ -4,5 +4,6 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { CardType } from './cardType'; -export type ChatMessageDataCardType = number | null; +export type ChatMessageDataCardType = CardType | null; diff --git a/negodata/front/src/api/generated/model/enumOption.ts b/negodata/front/src/api/generated/model/chatMessageDataScript.ts similarity index 60% rename from negodata/front/src/api/generated/model/enumOption.ts rename to negodata/front/src/api/generated/model/chatMessageDataScript.ts index 3b1b7e1..de3a462 100644 --- a/negodata/front/src/api/generated/model/enumOption.ts +++ b/negodata/front/src/api/generated/model/chatMessageDataScript.ts @@ -5,8 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export interface EnumOption { - value: number; - name: string; - label: string; -} +export type ChatMessageDataScript = string | null; diff --git a/negodata/front/src/api/generated/model/resEnumsMsg.ts b/negodata/front/src/api/generated/model/chatMessageDataStep.ts similarity index 71% rename from negodata/front/src/api/generated/model/resEnumsMsg.ts rename to negodata/front/src/api/generated/model/chatMessageDataStep.ts index f2ddea5..1e26300 100644 --- a/negodata/front/src/api/generated/model/resEnumsMsg.ts +++ b/negodata/front/src/api/generated/model/chatMessageDataStep.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResEnumsMsg = string | null; +export type ChatMessageDataStep = string | null; diff --git a/negodata/front/src/api/generated/model/chatSender.ts b/negodata/front/src/api/generated/model/chatSender.ts new file mode 100644 index 0000000..a985499 --- /dev/null +++ b/negodata/front/src/api/generated/model/chatSender.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/deliveryType.ts b/negodata/front/src/api/generated/model/deliveryType.ts new file mode 100644 index 0000000..2f51973 --- /dev/null +++ b/negodata/front/src/api/generated/model/deliveryType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 92ade6b..987f649 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -17,13 +17,18 @@ export * from './cardDataNumber'; export * from './cardDataScript'; export * from './cardDataUpdatedAt'; export * from './cardDataUserId'; +export * from './cardStatus'; +export * from './cardType'; export * from './chatMessageData'; export * from './chatMessageDataCardId'; export * from './chatMessageDataCardType'; export * from './chatMessageDataCardUsedYn'; export * from './chatMessageDataIndicatorValue'; +export * from './chatMessageDataScript'; +export * from './chatMessageDataStep'; +export * from './chatSender'; export * from './companyData'; -export * from './enumOption'; +export * from './deliveryType'; export * from './errorInfo'; export * from './errorInfoCode'; export * from './errorInfoDesc'; @@ -80,6 +85,8 @@ export * from './quotationSettingData'; export * from './quotationSettingDataCreatedAt'; export * from './quotationSettingDataUpdatedAt'; export * from './quotationSettingDataUserId'; +export * from './quotationStatus'; +export * from './quotationType'; export * from './reqCheckCodes'; export * from './reqCreateAccount'; export * from './reqCreateCard'; @@ -179,9 +186,6 @@ export * from './resDeleteQuotationSetting'; export * from './resDeleteQuotationSettingMsg'; export * from './resDeleteSupplier'; export * from './resDeleteSupplierMsg'; -export * from './resEnums'; -export * from './resEnumsEnums'; -export * from './resEnumsMsg'; export * from './resItem'; export * from './resItemCategories'; export * from './resItemCategoriesMsg'; @@ -248,6 +252,7 @@ export * from './sessionDataBidPrice'; export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectPrice'; export * from './sessionDataRejectReason'; +export * from './sessionStatus'; export * from './supplierData'; export * from './supplierDataCode'; export * from './supplierDataCreatedAt'; @@ -256,6 +261,7 @@ export * from './supplierDataManagerEmail'; export * from './supplierDataManagerName'; export * from './supplierDataPriority'; export * from './supplierDataUpdatedAt'; +export * from './userRole'; export * from './validationError'; export * from './validationErrorCtx'; export * from './validationErrorLocItem'; \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/itemDataDeliveryType.ts b/negodata/front/src/api/generated/model/itemDataDeliveryType.ts index df68667..e3e57c3 100644 --- a/negodata/front/src/api/generated/model/itemDataDeliveryType.ts +++ b/negodata/front/src/api/generated/model/itemDataDeliveryType.ts @@ -4,5 +4,6 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { DeliveryType } from './deliveryType'; -export type ItemDataDeliveryType = number | null; +export type ItemDataDeliveryType = DeliveryType | null; diff --git a/negodata/front/src/api/generated/model/listCardsParams.ts b/negodata/front/src/api/generated/model/listCardsParams.ts index 70451cc..ed85c84 100644 --- a/negodata/front/src/api/generated/model/listCardsParams.ts +++ b/negodata/front/src/api/generated/model/listCardsParams.ts @@ -10,6 +10,10 @@ export type ListCardsParams = { * 카드명/카드번호/스크립트 검색 */ search?: string | null; +/** + * 탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드 + */ +is_wildcard?: boolean | null; /** * @minimum 1 */ diff --git a/negodata/front/src/api/generated/model/listQuotationsParams.ts b/negodata/front/src/api/generated/model/listQuotationsParams.ts index 31679fb..a526f5f 100644 --- a/negodata/front/src/api/generated/model/listQuotationsParams.ts +++ b/negodata/front/src/api/generated/model/listQuotationsParams.ts @@ -6,6 +6,10 @@ */ export type ListQuotationsParams = { +/** + * 견적명/견적번호 검색 + */ +search?: string | null; /** * 상태 필터(정확히 일치) */ diff --git a/negodata/front/src/api/generated/model/quotationCardDataType.ts b/negodata/front/src/api/generated/model/quotationCardDataType.ts index b9b65d9..f0a7c0c 100644 --- a/negodata/front/src/api/generated/model/quotationCardDataType.ts +++ b/negodata/front/src/api/generated/model/quotationCardDataType.ts @@ -4,5 +4,6 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { CardType } from './cardType'; -export type QuotationCardDataType = number | null; +export type QuotationCardDataType = CardType | null; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index 0665b08..ae7106b 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -4,6 +4,8 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { QuotationType } from './quotationType'; +import type { QuotationStatus } from './quotationStatus'; import type { QuotationDataManagerName } from './quotationDataManagerName'; import type { QuotationDataManagerEmail } from './quotationDataManagerEmail'; import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber'; @@ -25,9 +27,9 @@ export interface QuotationData { version_id: string; name: string; number: string; - type: number; + type: QuotationType; round?: number; - status: number; + status: QuotationStatus; start_time: string; end_time: string; manager_name?: QuotationDataManagerName; diff --git a/negodata/front/src/api/generated/model/quotationStatus.ts b/negodata/front/src/api/generated/model/quotationStatus.ts new file mode 100644 index 0000000..1758d54 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationStatus.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationType.ts b/negodata/front/src/api/generated/model/quotationType.ts new file mode 100644 index 0000000..87f9dce --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/resCardList.ts b/negodata/front/src/api/generated/model/resCardList.ts index 93f380a..90625fd 100644 --- a/negodata/front/src/api/generated/model/resCardList.ts +++ b/negodata/front/src/api/generated/model/resCardList.ts @@ -15,4 +15,6 @@ export interface ResCardList { page?: number; size?: number; cards?: CardData[]; + total_nego?: number; + total_wild?: number; } diff --git a/negodata/front/src/api/generated/model/resEnums.ts b/negodata/front/src/api/generated/model/resEnums.ts deleted file mode 100644 index e234dec..0000000 --- a/negodata/front/src/api/generated/model/resEnums.ts +++ /dev/null @@ -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; -} diff --git a/negodata/front/src/api/generated/model/resEnumsEnums.ts b/negodata/front/src/api/generated/model/resEnumsEnums.ts deleted file mode 100644 index 2afe996..0000000 --- a/negodata/front/src/api/generated/model/resEnumsEnums.ts +++ /dev/null @@ -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[]}; diff --git a/negodata/front/src/api/generated/model/resMe.ts b/negodata/front/src/api/generated/model/resMe.ts index 291260f..226eea7 100644 --- a/negodata/front/src/api/generated/model/resMe.ts +++ b/negodata/front/src/api/generated/model/resMe.ts @@ -9,6 +9,7 @@ import type { ResMeMsg } from './resMeMsg'; import type { ResMeName } from './resMeName'; import type { ResMeEmail } from './resMeEmail'; import type { ResMeContactNumber } from './resMeContactNumber'; +import type { UserRole } from './userRole'; import type { ResMeCompany } from './resMeCompany'; export interface ResMe { @@ -19,7 +20,6 @@ export interface ResMe { name?: ResMeName; email?: ResMeEmail; contact_number?: ResMeContactNumber; - role?: number; - role_label?: string; + role?: UserRole; company?: ResMeCompany; } diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index 04b2986..b48fec3 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -4,6 +4,8 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { QuotationType } from './quotationType'; +import type { SessionStatus } from './sessionStatus'; import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidAt } from './sessionDataBidAt'; import type { SessionDataRejectReason } from './sessionDataRejectReason'; @@ -17,9 +19,9 @@ export interface SessionData { item_id: string; qt_number: string; qt_round: number; - qt_type: number; + qt_type: QuotationType; target_price: number; - status: number; + status: SessionStatus; bid_price?: SessionDataBidPrice; bid_at?: SessionDataBidAt; end_time: string; diff --git a/negodata/front/src/api/generated/model/sessionDataRejectDeliveryType.ts b/negodata/front/src/api/generated/model/sessionDataRejectDeliveryType.ts index 475c6e0..90c8920 100644 --- a/negodata/front/src/api/generated/model/sessionDataRejectDeliveryType.ts +++ b/negodata/front/src/api/generated/model/sessionDataRejectDeliveryType.ts @@ -4,5 +4,6 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { DeliveryType } from './deliveryType'; -export type SessionDataRejectDeliveryType = number | null; +export type SessionDataRejectDeliveryType = DeliveryType | null; diff --git a/negodata/front/src/api/generated/model/sessionStatus.ts b/negodata/front/src/api/generated/model/sessionStatus.ts new file mode 100644 index 0000000..42fa328 --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionStatus.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/userRole.ts b/negodata/front/src/api/generated/model/userRole.ts new file mode 100644 index 0000000..14c2610 --- /dev/null +++ b/negodata/front/src/api/generated/model/userRole.ts @@ -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; diff --git a/negodata/front/src/components/ImageDropzone.tsx b/negodata/front/src/components/ImageDropzone.tsx index 1f89cb1..7a22b8c 100644 --- a/negodata/front/src/components/ImageDropzone.tsx +++ b/negodata/front/src/components/ImageDropzone.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, DragEvent, ChangeEvent } from 'react'; +import React, { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react'; import { Upload, Image as ImageIcon, X, AlertCircle, Link2, Loader2 } from 'lucide-react'; interface ImageDropzoneProps { @@ -31,6 +31,12 @@ export default function ImageDropzone({ const [urlDraft, setUrlDraft] = useState(''); const fileInputRef = useRef(null); + // 현재 값이 일반 URL 이면 입력칸에 그대로 노출(보기/수정/복사 가능). + // base64 data URL(파일 업로드 폴백)은 거대 문자열이라 칸엔 넣지 않는다. + useEffect(() => { + setUrlDraft(value && /^https?:\/\//i.test(value) ? value : ''); + }, [value]); + const processFile = async (file: File) => { setError(null); @@ -107,8 +113,7 @@ export default function ImageDropzone({ const url = urlDraft.trim(); if (!url) return; setError(null); - onChange(url); - setUrlDraft(''); + onChange(url); // value 변경 → 위 effect 가 입력칸을 적용된 URL 로 다시 채움 }; return ( diff --git a/negodata/front/src/components/ui/data-table.tsx b/negodata/front/src/components/ui/data-table.tsx index 4ecfa1c..4218593 100644 --- a/negodata/front/src/components/ui/data-table.tsx +++ b/negodata/front/src/components/ui/data-table.tsx @@ -97,10 +97,7 @@ export function DataTable({ const detailCols = mobileCols.filter((c) => c !== primaryCol) return ( - // 전환 기준은 뷰포트가 아니라 '표가 들어갈 실제 폭'(컨테이너). 사이드바가 폭을 먹어도 - // 어긋나지 않는다. 컨테이너 ≥ 48rem(@3xl)이면 표, 그 미만은 카드 리스트. (Tailwind v4 내장 @container)
- {/* 넓을 때: 표 — 그래도 넘치면 Table 내부에서 가로 스크롤 */}
@@ -184,7 +181,6 @@ export function DataTable({
- {/* 좁을 때(컨테이너 < 48rem): 카드 리스트 — 행=카드, 컬럼=라벨:값 */}
{data.length > 0 ? ( data.map((row) => { @@ -218,7 +214,6 @@ export function DataTable({
)} - {/* 상세: 기본=라벨:값 한 줄(컴팩트) / mobileBlock=라벨 아래 풀폭 */} {detailCols.length > 0 && (
{detailCols.map((c, i) => diff --git a/negodata/front/src/features/auth/service.ts b/negodata/front/src/features/auth/service.ts index 005f408..4245c5c 100644 --- a/negodata/front/src/features/auth/service.ts +++ b/negodata/front/src/features/auth/service.ts @@ -7,6 +7,8 @@ import { import type {ResMe} from '../../api/generated/model/resMe'; import type {ErrorInfo} from '../../api/generated/model/errorInfo'; import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth'; +import {UserRole as UserRoleCode} from '../../api/generated/model'; +import {USER_ROLE_LABEL} from '../../lib/enumLabels'; const ACCESS_KEY = 'negodata.accessToken'; const REFRESH_KEY = 'negodata.refreshToken'; @@ -26,7 +28,7 @@ function toAuthUser(me: ResMe): AuthUser { loginId: me.id ?? '', email: me.email ?? '', contact: me.contact_number ?? '', - role: (me.role_label as UserRole) || '일반', + role: (USER_ROLE_LABEL[me.role ?? UserRoleCode.USER] ?? '일반') as UserRole, }; } diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index 70996b5..27368be 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -54,10 +54,11 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) { }, { header: '스크립트', + headClassName: 'w-[22rem]', // 컬럼 폭 고정 → 긴 스크립트가 표를 늘리지 않게 cellClassName: 'font-mono text-muted-foreground', mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭 cell: (card) => ( -
+
{card.scriptPreview}
), diff --git a/negodata/front/src/features/cards/hooks/useCardFilters.ts b/negodata/front/src/features/cards/hooks/useCardFilters.ts deleted file mode 100644 index e3a98f8..0000000 --- a/negodata/front/src/features/cards/hooks/useCardFilters.ts +++ /dev/null @@ -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('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 }; -} diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index ce06312..3afbc4a 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -1,11 +1,11 @@ -import { useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; import { useListCards, createCard, updateCard, deleteCard, - getListCardsQueryKey, } from '@/api/generated/card/card'; +import type { ListCardsParams } from '@/api/generated/model/listCardsParams'; import type { Descendant } from 'slate'; import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard'; import type { ResCard } from '@/api/generated/model/resCard'; @@ -13,8 +13,6 @@ import type { NegotiationCard } from '@/types'; import { mapCardData, toCardStatusCode } from '../types'; import { serializeToText } from '../editor'; -const LIST_PARAMS = { size: 100 }; - // 카드 폼이 넘기는 입력값(편집/생성 공통). // 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다. export type CardInput = { @@ -50,12 +48,13 @@ function toReq(input: CardInput): ReqCreateCard { // 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). // 실패 시 throw → 호출부(폼/페이지)에서 toast 처리. -export function useCards() { +export function useCards(params: ListCardsParams) { const queryClient = useQueryClient(); - const cardsQuery = useListCards(LIST_PARAMS); + // 테이블용(현재 페이지). 페이지 이동 시 placeholderData 로 이전 데이터 유지(깜빡임 방지). + const cardsQuery = useListCards(params, { query: { placeholderData: keepPreviousData } }); - const refresh = () => - queryClient.invalidateQueries({ queryKey: getListCardsQueryKey(LIST_PARAMS) }); + // 변경 후 모든 카드 목록 쿼리(파라미터별 키 전부) 재조회. + const refresh = () => queryClient.invalidateQueries({ queryKey: ['/v1/card/list'] }); const createCardFn = async (input: CardInput) => { const msg = cardError(await createCard(toReq(input))); @@ -74,9 +73,15 @@ export function useCards() { // customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards. const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData); + const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션) + const totalNego = cardsQuery.data?.total_nego ?? 0; // 협상카드 탭 카운트 + const totalWild = cardsQuery.data?.total_wild ?? 0; // 와일드카드 탭 카운트 return { cards, + total, + totalNego, + totalWild, createCard: createCardFn, updateCard: updateCardFn, deleteCard: deleteCardFn, diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index 173ed9f..19556c4 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -1,18 +1,16 @@ import type { NegotiationCard } from '@/types'; import type { CardData } from '@/api/generated/model/cardData'; +import { CardStatus } from '@/api/generated/model'; export type { NegotiationCard }; // 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드. export type CardTab = 'ALL' | 'CARD' | 'WILD'; -// 카드 status 코드(서버 CardStatus enum) ↔ UI 문자열. ACTIVE=1 / INACTIVE=2. -export const CARD_STATUS_ACTIVE = 1; -export const CARD_STATUS_INACTIVE = 2; export const toCardStatusCode = (s: 'ACTIVE' | 'INACTIVE') => - s === 'ACTIVE' ? CARD_STATUS_ACTIVE : CARD_STATUS_INACTIVE; + s === 'ACTIVE' ? CardStatus.ACTIVE : CardStatus.INACTIVE; export const toCardStatusLabel = (code?: number): 'ACTIVE' | 'INACTIVE' => - code === CARD_STATUS_INACTIVE ? 'INACTIVE' : 'ACTIVE'; + code === CardStatus.INACTIVE ? 'INACTIVE' : 'ACTIVE'; // 서버 CardData(nego_cards/wild_cards 통합) → UI NegotiationCard. export function mapCardData(c: CardData): NegotiationCard { diff --git a/negodata/front/src/features/partners/hooks/usePartners.ts b/negodata/front/src/features/partners/hooks/usePartners.ts index 88e172d..34beaed 100644 --- a/negodata/front/src/features/partners/hooks/usePartners.ts +++ b/negodata/front/src/features/partners/hooks/usePartners.ts @@ -27,9 +27,7 @@ function supplierError(res: ResSupplier): string | null { return r.desc || '협력사 등록에 실패했습니다.'; } -// 협력사 서버 데이터 + CRUD. -// - params: 테이블용 서버 페이지네이션/검색/우선순위 (useServerList 가 만든다) -// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리. + export function usePartners(params: ListSuppliersParams) { const queryClient = useQueryClient(); diff --git a/negodata/front/src/features/partners/types.ts b/negodata/front/src/features/partners/types.ts index 539e01f..3608843 100644 --- a/negodata/front/src/features/partners/types.ts +++ b/negodata/front/src/features/partners/types.ts @@ -1,11 +1,4 @@ -import type { SupplierData } from '@/api/generated/model/supplierData'; - -// UI에서 쓰는 협력사 타입. 서버 SupplierData에 화면 전용 파생 필드만 얹는다. -// (level/rank/status 등 서버 미연동 가짜 필드는 두지 않는다 — 표시 가능한 건 priority뿐.) -export type Partner = SupplierData & { - deleted?: boolean; - id?: string; -}; +export type { Partner } from '@/types'; // 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외). export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW']; diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 33ce445..26b1746 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem'; import type { ReqUpdateItem as ItemUpdate } from '@/api/generated/model/reqUpdateItem'; -import { useListEnums } from '@/api/generated/enums/enums'; +import { DELIVERY_TYPE_OPTIONS } from '@/lib/enumLabels'; import { uploadItemImage } from '@/api/generated/item/item'; import { showToast } from '@/lib/notify'; import ImageDropzone from '@/components/ImageDropzone'; @@ -121,9 +121,7 @@ export function ProductFormSheet({ defaultValues: buildDefaults(mode, product), }); - // 배송 형태 코드(delivery_type) 선택지는 서버 enum 에서 가져온다. - const { data: enumsData } = useListEnums(); - const deliveryTypes = enumsData?.enums?.delivery_type ?? []; + const deliveryTypes = DELIVERY_TYPE_OPTIONS; // minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로. const onValid = async (v: FormValues) => { diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index f9cfd29..bcbfbd2 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -33,7 +33,7 @@ export function ProductTable({ rowKey={(prod) => prod.item_id} onRowClick={onRowClick} selection={{ selectedKeys: selectedIds, onSelectionChange }} - empty="부합하는 B2B 상품 데이터 정보가 식별되지 않습니다." + empty="부합하는 상품 데이터 정보가 식별되지 않습니다." footer={ Math.round((price || 0) * 0.83); diff --git a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx similarity index 80% rename from negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx rename to negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index 6a56739..4cc3db0 100644 --- a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -6,8 +6,10 @@ import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; +import { QuotationType } from '@/api/generated/model'; +import { QUOTATION_TYPE_OPTIONS } from '../types'; -type CreateQuotationWizardProps = { +type QuotationCreateModalProps = { open: boolean; products: Product[]; partners: Partner[]; @@ -17,7 +19,7 @@ type CreateQuotationWizardProps = { onClose: () => void; }; -export function CreateQuotationWizard({ +export function QuotationCreateModal({ open, products, partners, @@ -25,22 +27,25 @@ export function CreateQuotationWizard({ quotationSettings, onCreate, onClose, -}: CreateQuotationWizardProps) { +}: QuotationCreateModalProps) { const [step, setStep] = useState(1); const [title, setTitle] = useState(''); - const [type, setType] = useState<'RE_NEGOTIATION' | 'RE_ESTIMATE'>('RE_NEGOTIATION'); + const [type, setType] = useState(QuotationType.REQUOTE); const [productId, setProductId] = useState(''); const [selectedPartnerIds, setSelectedPartnerIds] = useState([]); const [dueDate, setDueDate] = useState('2026-06-15T18:00'); const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [selectedCardIds, setSelectedCardIds] = useState([]); const [submitting, setSubmitting] = useState(false); + const typeOptions = QUOTATION_TYPE_OPTIONS; if (!open) return null; const togglePartner = (id: string) => setSelectedPartnerIds((prev) => - prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id], + type === QuotationType.RENEGO + ? prev.includes(id) ? [] : [id] + : prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id], ); const toggleCard = (id: string) => setSelectedCardIds((prev) => @@ -73,8 +78,8 @@ export function CreateQuotationWizard({
- 협상견적 생성 중… - 견적 · 협상 세션 등록 중 + 협상견적 생성 중… + 견적 · 협상 세션 등록 중
)} @@ -84,7 +89,7 @@ export function CreateQuotationWizard({
- 신규 협상견적 등록 (단계 {step}/3) + 신규 협상견적 등록 (단계 {step}/3)
- {statusKey === '견적진행중' && ( - - )} - -
-
- - {/* DB mapping info cards */} - {showHeaderCards && ( -
- {/* 좌측 컬럼: 견적정보 + 진행상태 */} -
- {/* Quotations */} -
- - 견적 정보 - -
-
- 견적명 - {q_name} -
-
- 견적번호 - {q_number} -
-
- 유형 - - {q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'} - -
-
- 차수 - {q_round}차 -
-
- 견적상태 - - - {statusKey || q_status} - -
-
- 마감시각 - {q_end_time} -
-
- 담당자 - {q_manager_name} ({q_manager_email}) -
-
- 메모 - - {q_memo} - -
-
-
- - {/* Bid Summary */} -
- - 견적 진행상태/결과 - -
-
- 식별자 - {bidSummaryObj.bid_summary_id} -
-
- 진행/결과 상태 - {bidSummaryObj.status} -
-
- 반복횟수 - {bidSummaryObj.qt_iteration}회 -
-
- 우선협상자 존재여부 - - {bidSummaryObj.has_preferred ? '존재' : '미존재'} - -
-
- 우선협상자명 - {bidSummaryObj.preferred_sp_name} -
-
- 동가입찰정보 - - {bidSummaryObj.equal_data} - -
-
-
-
- - {/* 우측 컬럼: 상품정보 + 세팅 */} -
- {/* 상품 정보 (협상 대상 상품) */} -
- - 상품 정보 - - {currentProduct ? ( -
-
- {currentProduct.image_url ? ( - {currentProduct.name - ) : ( - - )} -
-
- - {currentProduct.name || '-'} - -
- {productSpecRows.map((r) => ( -
- {r.label} - - {r.value} - -
- ))} -
-
-
- ) : ( -
상품 정보가 비어있습니다.
- )} -
- - {/* Quotation Settings */} -
- - 견적 세팅 - - {selectedSettingObj ? ( -
-
- 목표 마진율 - {selectedSettingObj.target_margin} -
-
- 앵커링 설정 값 - {selectedSettingObj.anchoring_value} -
-
- 카드 사용 횟수 - {selectedSettingObj.card_use_count} -
-
- ) : ( -
적용된 견적 세팅이 비어있습니다.
- )} -
-
-
- )} -
- - {/* Tabs */} -
-
- {tabs.map((tab) => { - const Icon = tab.icon; - return ( - - ); - })} -
-
- - {/* Tab content */} -
- - {/* Tab: Sessions Status */} - {activeTab === 'status' && ( -
-
- - - - 세션 ID - 협력사 - 협상 URL - 상품 - 협상상태 - 목표가 - 투찰가 - 투찰시각 - 마감시각 - 거절사유 - 거절가격 - 거절배송방식 - - - - {sessionViews.length === 0 && ( - - - 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) - - - )} - {sessionViews.map((sess) => ( - - {sess.session_id} - -
- {sess.supplier_name} - -
-
- - {sess.url ? ( -
- - 세션 열기 - - -
- ) : ( - - - )} -
- {sess.item_name} - - - {sess.status} - - - - ₩{sess.target_price?.toLocaleString() || '-'} - - - {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} - - {sess.bid_at || '-'} - {sess.end_time || '-'} - {sess.reject_reason || '-'} - {sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'} - {sess.reject_delivery_type || '-'} -
- ))} -
-
-
-
- )} - - {/* Tab: Quotation Cards */} - {activeTab === 'cards' && ( -
-
- - - - 세션 카드 ID - 카드 이름 - 타입 - - - - {quotationCardViews.length > 0 ? ( - quotationCardViews.map((qc) => ( - - {qc.session_card_id} - - {qc.card_id ? ( - - {qc.card_name} - - ) : ( - qc.card_name - )} - - - - {qc.type} - - - - )) - ) : ( - - - 사용된 협상 카드가 없습니다. (리스트가 비어 있습니다) - - - )} - -
-
-
- )} - - {/* Tab: Chat */} - {activeTab === 'chat' && ( -
- {/* Sessions list */} -
-
- 참여자 협력사 리스트 -
-
- {serverSessions.length === 0 && ( -
- 참여 협상 세션이 없습니다. (리스트가 비어 있습니다) -
- )} - {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 ( - - ); - })} -
-
- - {/* Chat zone */} -
-
-
- 협력사: {currentSupplierName} -
-
- 기록: {chatMessages.length} 메시지 -
-
- -
- {!effectiveSessionId ? ( -
- 선택된 협력사가 없습니다. -
- ) : chatMessages.length === 0 ? ( -
- 기록된 협상 대화가 없습니다. -
- ) : ( - 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 ( -
-
-
- {isBot ? 'Negosium Bot' : currentSupplierName} - · - #{m.index} -
- -
-
제시 단가 ₩{Number(m.target_price).toLocaleString()}
- {usedCard && ( -
- {/* 헤더: 어떤 카드인지(번호·이름·종류) */} -
- - 협상카드 - {usedCard.number && #{usedCard.number}} - {usedCard.name && · {usedCard.name}} - - {isWildCard ? '와일드' : '협상'} - -
- - {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} - {cardNodes ? ( -
- -
- ) : usedCard.script ? ( -

- {usedCard.script} -

- ) : null} - - {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} - {isWildCard && (usedCard.condition || usedCard.memo) && ( -
- {usedCard.condition && ( -
- 조건: {usedCard.condition} -
- )} - {usedCard.memo && ( -
- 메모: {usedCard.memo} -
- )} -
- )} -
- )} -
-
-
- ); - }) - )} -
- -
- - -
-
-
- )} - -
- - - ); -} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx new file mode 100644 index 0000000..7e359f3 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -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 ( +
+ {/* Sessions list */} +
+
+ 참여자 협력사 리스트 +
+
+ {serverSessions.length === 0 && ( +
+ 참여 협상 세션이 없습니다. (리스트가 비어 있습니다) +
+ )} + {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 ( + + ); + })} +
+
+ + {/* Chat zone */} +
+
+
+ 협력사: {currentSupplierName} +
+
+ {targetPrice != null && ( + + 목표가: ₩{Number(targetPrice).toLocaleString()} + + )} + + 기록: {chatMessages.length} 메시지 + +
+
+ +
+ {!effectiveSessionId ? ( +
+ 선택된 협력사가 없습니다. +
+ ) : chatMessages.length === 0 ? ( +
+ 기록된 협상 대화가 없습니다. +
+ ) : ( + 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 ( +
+
+
+ {isBot ? 'Negosium Bot' : currentSupplierName} + · + #{m.index} +
+ +
+ {/* 진행 단계(chats.meta.step). 주로 봇 턴에만 존재. */} + {m.step && ( +
+ {m.step} +
+ )} + {/* 말풍선 멘트(chats.meta.script). 봇=협상 스크립트, 협력사=입력값. */} + {m.script && ( +

{m.script}

+ )} + {/* 제시가: 협력사(user)가 실제로 제시한 가격만 표시. 목표가는 헤더 고정. + 가격 제시 턴이 아니면(target_price=0) 숨긴다(₩0 오표시 방지). */} + {!isBot && m.target_price > 0 && ( +
제시가 ₩{Number(m.target_price).toLocaleString()}
+ )} + {usedCard && ( +
+ {/* 헤더: 어떤 카드인지(번호·이름·종류) */} +
+ + 협상카드 + {usedCard.number && #{usedCard.number}} + {usedCard.name && · {usedCard.name}} + + {isWildCard ? '와일드' : '협상'} + +
+ + {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} + {cardNodes ? ( +
+ +
+ ) : usedCard.script ? ( +

+ {usedCard.script} +

+ ) : null} + + {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} + {isWildCard && (usedCard.condition || usedCard.memo) && ( +
+ {usedCard.condition && ( +
+ 조건: {usedCard.condition} +
+ )} + {usedCard.memo && ( +
+ 메모: {usedCard.memo} +
+ )} +
+ )} +
+ )} +
+
+
+ ); + }) + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx new file mode 100644 index 0000000..347c3c7 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -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 ( + + + {title} + + {children} + + ); +} + +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 ( +
+ {/* 좌측 컬럼: 견적정보 + 진행상태 */} +
+ {/* Quotations */} + +
+ + + + + + + + + + +
+
+ + {/* Bid Summary */} + +
+ + + + + + + + {bidSummaryObj.equal_data} + + +
+
+
+ + {/* 우측 컬럼: 상품정보 + 세팅 */} +
+ {/* 상품 정보 (협상 대상 상품) */} + + {currentProduct ? ( +
+
+ {currentProduct.image_url ? ( + {currentProduct.name + ) : ( + + )} +
+
+ + {currentProduct.name || '-'} + +
+ {productSpecRows.map((r) => ( + + ))} +
+
+
+ ) : ( +
상품 정보가 비어있습니다.
+ )} +
+ + {/* Quotation Settings */} + + {selectedSettingObj ? ( +
+ + + +
+ ) : ( +
적용된 견적 세팅이 비어있습니다.
+ )} +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/InfoField.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/InfoField.tsx new file mode 100644 index 0000000..ea14d91 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/InfoField.tsx @@ -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 ( +
+ {label} + {children ?? ( + + {value} + + )} +
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx new file mode 100644 index 0000000..0df411c --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx @@ -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; + +export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: CardView[] }) { + return ( +
+
+ + + + 세션 카드 ID + 카드 이름 + 타입 + + + + {quotationCardViews.length > 0 ? ( + quotationCardViews.map((qc) => ( + + {qc.session_card_id} + + {qc.card_id ? ( + + {qc.card_name} + + ) : ( + qc.card_name + )} + + + + {qc.type} + + + + )) + ) : ( + + + 사용된 협상 카드가 없습니다. (리스트가 비어 있습니다) + + + )} + +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx new file mode 100644 index 0000000..a1b6c7d --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -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; + +export function SessionsStatusTab({ + sessionViews, + onOpenChat, +}: { + sessionViews: SessionView[]; + onOpenChat: (sessionId: string) => void; +}) { + return ( +
+
+ + + + 세션 ID + 협력사 + 협상 URL + 상품 + 협상상태 + 목표가 + 투찰가 + 투찰시각 + 마감시각 + 거절사유 + 거절가격 + 거절배송방식 + + + + {sessionViews.length === 0 && ( + + + 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) + + + )} + {sessionViews.map((sess) => ( + + {sess.session_id} + +
+ {sess.supplier_name} + +
+
+ + {sess.url ? ( +
+ + 세션 열기 + + +
+ ) : ( + - + )} +
+ {sess.item_name} + + {sessionStatusLabel(sess.status)} + + + ₩{sess.target_price?.toLocaleString() || '-'} + + + {sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'} + + {sess.bid_at || '-'} + {sess.end_time || '-'} + {sess.reject_reason || '-'} + + {sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'} + + {sess.reject_delivery_type || '-'} +
+ ))} +
+
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx new file mode 100644 index 0000000..1120e1c --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/StatusPill.tsx @@ -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 = { + 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 ( + + {children} + + ); +} + +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.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 ( + + + {quotationStatusLabel(status)} + + ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx new file mode 100644 index 0000000..6af33b5 --- /dev/null +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -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('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(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 ( +
+
+ +
+ + {/* Header */} +
+
+
+
+ 견적 상세 // {q_number} +
+ {q_name} +
+ +
+ + {/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */} + {(() => { + const canClose = quotation.status !== QuotationStatus.CLOSED; + return ( + + ); + })()} + +
+
+ + {/* DB mapping info cards */} + {showHeaderCards && ( + + )} +
+ + {/* Tabs */} +
+
+ {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + ); + })} +
+
+ + {/* Tab content */} +
+ {activeTab === 'status' && ( + + )} + + {activeTab === 'cards' && } + + {activeTab === 'chat' && ( + + )} +
+
+
+ ); +} diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 9dc943a..a5bca96 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react'; import { Clock, Building2 } from 'lucide-react'; import { DataTable } from '@/components/ui/data-table'; -import { type Estimate, type Product, normalizeQuotationStatus } from '../types'; +import { type Estimate, type Product, quotationStatusLabel, quotationTypeLabel } from '../types'; +import { QuotationType, QuotationStatus } from '@/api/generated/model'; type QuotationTableProps = { data: Estimate[]; @@ -10,15 +11,15 @@ type QuotationTableProps = { footer?: ReactNode; }; -const statusBadgeClass = (status?: string | null) => { - switch (normalizeQuotationStatus(status)) { - case '견적생성': +const statusBadgeClass = (status?: number | null) => { + switch (status) { + case QuotationStatus.CREATED: return 'bg-yellow-50 text-yellow-700 border-yellow-300 animate-pulse'; - case '견적진행중': + case QuotationStatus.ACTIVE: return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40'; - case '견적마감': + case QuotationStatus.CLOSED: return 'bg-blue-50 text-blue-700 border-blue-300'; - case '협상보류': + case QuotationStatus.ON_HOLD: return 'bg-red-50 text-red-700 border-red-300'; default: return 'bg-zinc-100 text-zinc-600'; @@ -63,12 +64,12 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati cell: (est) => ( - {est.type === 'RE_NEGOTIATION' ? '재협상' : '재견적'} + {quotationTypeLabel(est.type)} ), }, @@ -85,7 +86,7 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati - {normalizeQuotationStatus(est.status) || est.status} + {quotationStatusLabel(est.status)} ), }, diff --git a/negodata/front/src/features/quotations/hooks/useQuotationFilters.ts b/negodata/front/src/features/quotations/hooks/useQuotationFilters.ts deleted file mode 100644 index 4a7bbc0..0000000 --- a/negodata/front/src/features/quotations/hooks/useQuotationFilters.ts +++ /dev/null @@ -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, - }; -} diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index b4e636e..dcfb2cf 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; import { useListItems } from '@/api/generated/item/item'; import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListCards } from '@/api/generated/card/card'; @@ -14,22 +14,20 @@ import { useListQuotations, useCreateQuotation, useStopQuotation, - getListQuotationsQueryKey, + getGetQuotationQueryKey, + getGetQuotationSessionsQueryKey, } from '@/api/generated/quotation/quotation'; +import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams'; import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation'; -import type { ItemData } from '@/api/generated/model/itemData'; -import type { SupplierData } from '@/api/generated/model/supplierData'; -import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData'; -import type { QuotationData } from '@/api/generated/model/quotationData'; -import type { CardData } from '@/api/generated/model/cardData'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; -import type { Estimate } from '@/types'; -import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; +import type { Estimate } from '../types'; +import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; +import { QuotationStatus } from '@/api/generated/model'; export type CreateQuotationInput = { title: string; - type: 'RE_NEGOTIATION' | 'RE_ESTIMATE'; + type: number; // QuotationType 코드 (1=재협상, 2=재견적) productId: string; partnerIds: string[]; dueDate: string; // datetime-local 원본값 @@ -46,53 +44,61 @@ export type SettingInput = { // 견적 화면 데이터 허브. // 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다. // (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태) -export function useQuotations() { +export function useQuotations(params: ListQuotationsParams) { const queryClient = useQueryClient(); const itemsQuery = useListItems({ size: 100 }); const suppliersQuery = useListSuppliers({ size: 100 }); const cardsQuery = useListCards({ size: 100 }); const settingsQuery = useListSettings(); - const quotationsQuery = useListQuotations(undefined); + // 견적 목록은 서버 검색/상태·유형 필터/페이지네이션. 페이지 이동 시 이전 데이터 유지(깜빡임 방지). + const quotationsQuery = useListQuotations(params, { query: { placeholderData: keepPreviousData } }); const createSettingMutation = useCreateSetting(); const deleteSettingMutation = useDeleteSetting(); const createQuotationMutation = useCreateQuotation(); const stopQuotationMutation = useStopQuotation(); + // 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화). const invalidateQuotations = () => - queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) }); + queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] }); - const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem); - const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier); + const products = (itemsQuery.data?.items ?? []).map(mapItem); + const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier); // 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다. const quotationSettings = ( - unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? [] + settingsQuery.data?.settings ?? [] ).map(mapSetting); const [quotations, setQuotations] = useState([]); useEffect(() => { - const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations; + const qs = quotationsQuery.data?.quotations; if (qs) setQuotations(qs.map(mapQuotation)); }, [quotationsQuery.data]); + // 서버 전체 건수(선택 필터 반영) — 페이지네이션용. + const total = quotationsQuery.data?.total ?? 0; // 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다. - const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData); + const cards = (cardsQuery.data?.cards ?? []).map(mapCardData); - // 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화. - const stopNegotiation = async (id: string, name: string) => { - if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return; + // 견적 마감 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속 + 협상생성 세션은 미참여로 전이). + // 성공 시 목록 무효화로 서버값 재동기화. + const closeQuotation = async (id: string, name: string) => { + if (!(await confirm({ title: '견적 마감', description: `[${name}] 견적을 마감하시겠습니까? 마감하면 진행 중인 협상이 종료되고 되돌릴 수 없습니다.`, confirmText: '마감', destructive: true }))) return; // 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영. - setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '견적마감' } : e))); + setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: QuotationStatus.CLOSED } : e))); stopQuotationMutation.mutate( { qtId: id }, { onSuccess: () => { invalidateQuotations(); - showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info'); + // 열려있는 상세 Sheet 도 즉시 동기화(단건 견적 상태 + 세션 상태 재조회). + queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(id) }); + queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(id) }); + showToast(`[${name}] 견적이 마감되었습니다.`, 'info'); }, onError: () => { invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백 - showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error'); + showToast('견적 마감에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error'); }, }, ); @@ -162,7 +168,7 @@ export function useQuotations() { const payload: ReqCreateQuotation = { qt_setting_id: input.settingId, name: input.title, - type: input.type === 'RE_ESTIMATE' ? 2 : 1, + type: input.type, end_time: new Date(input.dueDate).toISOString(), item_ids: [input.productId], supplier_ids: input.partnerIds, @@ -194,8 +200,9 @@ export function useQuotations() { partners, cards, quotations, + total, quotationSettings, - stopNegotiation, + closeQuotation, addSetting, deleteSetting, createQuotation, diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index 6220f64..50d1f0f 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -4,39 +4,48 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin import type { QuotationData } from '@/api/generated/model/quotationData'; import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; -import type { - Product, - Partner, - QuotationSetting, - Estimate, - ChatSession, - NegotiationCard, -} from '@/types'; +import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model'; +import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels'; +import { toMinPrice } from '@/features/products/types'; +import type { Product, Partner, NegotiationCard } from '@/types'; -export type { Product, Partner, QuotationSetting, Estimate, ChatSession, NegotiationCard } from '@/types'; +export type { Product, Partner, NegotiationCard } from '@/types'; + +export type Estimate = Partial & { + id?: string; + dueDate?: string; + title?: string; + productId?: string; + productName?: string; + partnerIds?: string[]; + participationCount?: number; + winnerPartnerId?: string | null; + finalPrice?: number; + isEqualPrice?: boolean; + usedCardIds?: string[]; + settingApplied?: boolean | string; +}; + +export interface QuotationSetting { + qt_setting_id: string; + user_id: string; + target_margin: string; + anchoring_value: string; + card_use_count: string; + created_at: string; + updated_at: string; + deleted: boolean; +} // ── 서버 응답 → UI 모델 매퍼 ───────────────────────────────────────────── -// customFetch 가 응답 본문을 그대로 반환하므로 query.data 가 곧 봉투(ResXxxList) — 추가 언랩 불필요. -export function unwrap(env: unknown): T | undefined { - return (env as T | undefined) ?? undefined; -} - export function mapItem(it: ItemData): Product { - return { ...it, id: it.item_id, minPrice: Math.round((it.price || 0) * 0.83), status: 'ACTIVE' } as Product; + return { ...it, id: it.item_id, minPrice: toMinPrice(it.price), status: 'ACTIVE' } as Product; } export function mapSupplier(sp: SupplierData): Partner { return { - supplier_id: sp.supplier_id, - company_id: sp.company_id, - name: sp.name, - code: sp.code ?? null, - manager_name: sp.manager_name ?? null, - manager_email: sp.manager_email ?? null, - priority: sp.priority ?? null, - created_at: sp.created_at ?? undefined, - updated_at: sp.updated_at ?? undefined, + ...sp, id: sp.supplier_id, managerName: sp.manager_name || '', managerEmail: sp.manager_email || '', @@ -66,8 +75,8 @@ export function mapQuotation(q: QuotationData): Estimate { ...(q as unknown as Partial), id: q.qt_id, title: q.name, - type: normalizeQuotationType(q.type), - status: normalizeQuotationStatus(q.status) || String(q.status ?? ''), + type: q.type, + status: q.status, settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭 productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용 productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백 @@ -89,47 +98,32 @@ function formatDueDate(end?: string | null): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } -// ── 견적상태 정규화(영문 enum / 한글 DDL 혼용 대응) ────────────────────── - export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류'; -export const QUOTATION_STATUS_FILTERS: QtStatusKey[] = [ - '견적생성', - '견적진행중', - '견적마감', - '협상보류', -]; +export const QUOTATION_STATUS_LABEL: Record = { + [QuotationStatus.CREATED]: '견적생성', + [QuotationStatus.ACTIVE]: '견적진행중', + [QuotationStatus.CLOSED]: '견적마감', + [QuotationStatus.ON_HOLD]: '협상보류', +}; +export const quotationStatusLabel = (s?: number | null): string => + s != null ? QUOTATION_STATUS_LABEL[s as QuotationStatus] ?? String(s) : ''; -// QuotationStatus 코드(SMALLINT) ↔ 한글 상태키. 영문 enum/한글 DDL/숫자 코드 혼용을 모두 흡수. -export function normalizeQuotationStatus(status?: string | number | null): QtStatusKey | '' { - switch (status) { - case 1: - case 'PROCESSING': - case '견적생성': - return '견적생성'; - case 2: - case 'ACTIVE': - case '견적진행중': - return '견적진행중'; - case 3: - case 'COMPLETED': - case '견적마감': - return '견적마감'; - case 4: - case 'STOPPED': - case '협상보류': - return '협상보류'; - default: - return ''; - } -} +export const QUOTATION_STATUS_OPTIONS = Object.values(QuotationStatus).map((value) => ({ + value, + label: QUOTATION_STATUS_LABEL[value], +})); -// QuotationType 코드(1=재협상, 2=재견적) ↔ UI 유형값. 이미 문자열이면 그대로 통과. -export function normalizeQuotationType(type?: string | number | null): 'RE_NEGOTIATION' | 'RE_ESTIMATE' { - if (type === 1 || type === 'RE_NEGOTIATION') return 'RE_NEGOTIATION'; - if (type === 2 || type === 'RE_ESTIMATE') return 'RE_ESTIMATE'; - return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE'; -} +export const QUOTATION_TYPE_LABEL: Record = { + [QuotationType.RENEGO]: '재협상', + [QuotationType.REQUOTE]: '재견적', +}; +export const quotationTypeLabel = (t?: number | null): string => + t != null ? QUOTATION_TYPE_LABEL[t as QuotationType] ?? String(t) : ''; +export const QUOTATION_TYPE_OPTIONS = [QuotationType.REQUOTE, QuotationType.RENEGO].map((value) => ({ + value, + label: QUOTATION_TYPE_LABEL[value], +})); // ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ──────── @@ -150,7 +144,7 @@ export type SessionView = { supplier_name: string; item_id: string; item_name: string; - status: string; + status: number; target_price: number; bid_price: number | null; bid_at: string; @@ -163,115 +157,38 @@ export type SessionView = { export type QuotationCardView = { session_card_id: string; - card_id: string | null; // 실제 카드 id(협상=nego_card_id, 와일드=wild_card_id) — /cards?edit= 링크용 + card_id: string | null; // 실제 카드 id(협상=nego_card_id, 와일드=wild_card_id) — /cards?detail= 링크용 card_name: string; type: string; }; -// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출. -export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryView { - if (est.id === 'est-1') { - return { - bid_summary_id: 'bid-summary-111-uuid', - status: '입찰진행중 (ACTIVE)', - qt_iteration: 2, - has_preferred: true, - preferred_sp_id: 'part-1', - preferred_sp_name: '(주)우성테크놀로지', - equal_data: '-', - }; - } - if (est.id === 'est-2') { - return { - bid_summary_id: 'bid-summary-222-uuid', - status: '입찰종료 (COMPLETED)', - qt_iteration: 1, - has_preferred: true, - preferred_sp_id: 'part-2', - preferred_sp_name: '대현정밀공업 (주)', - equal_data: JSON.stringify({ 'part-2': 730000, 'part-3': 730000 }), - }; - } - if (est.id === 'est-3') { - return { - bid_summary_id: 'bid-summary-333-uuid', - status: '입찰활성화 (ACTIVE)', - qt_iteration: 1, - has_preferred: false, - preferred_sp_id: null, - preferred_sp_name: '-', - equal_data: '-', - }; - } +export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView { + const winnerId = q.preferred_sp_id ?? null; return { - bid_summary_id: `bid-summary-${est.id}`, - status: est.status === 'COMPLETED' ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)', - qt_iteration: 1, - has_preferred: !!est.winnerPartnerId, - preferred_sp_id: est.winnerPartnerId || null, - preferred_sp_name: est.winnerPartnerId - ? partners.find((p) => p.id === est.winnerPartnerId)?.name || '-' - : '-', - equal_data: '-', + bid_summary_id: `bid-summary-${q.qt_id}`, + status: q.status === QuotationStatus.CLOSED ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)', + qt_iteration: q.iteration ?? 1, + has_preferred: !!winnerId, + preferred_sp_id: winnerId, + preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'), + equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_data : '-', }; } -// 협력사별 1개의 세션(sessions). 채팅 세션 + 상품/협력사 정보를 합성. -export function buildSessions( - est: Estimate, - sessions: ChatSession[], - partners: Partner[], - products: Product[], -): SessionView[] { - return sessions.map((sess) => { - const supplierObj = partners.find((p) => p.id === sess.id); - const matchedProduct = products.find((p) => p.id === est.productId); - - let reject_reason: string | null = null; - let reject_price: number | null = null; - let reject_delivery_type: string | null = null; - - if (sess.id === 'part-2' && est.id === 'est-1') { - reject_reason = '셀 공급 마진 미달로 단가 수용 한계 봉착'; - reject_price = 1480000; - reject_delivery_type = '특수 보온 수송 차량 필요'; - } - - return { - session_id: `sess-${est.id}-${sess.id}`, - qt_id: est.id ?? '', - supplier_id: sess.id, - supplier_name: supplierObj?.name || sess.partnerName, - item_id: est.productId || 'prod-1', - item_name: matchedProduct?.name || '부품', - status: sess.status || '협상중', - target_price: Math.round((matchedProduct?.price || 1000000) * 0.9), - bid_price: sess.currentBid || null, - bid_at: sess.bidTime || '2026-06-11 09:00', - reject_reason, - reject_price, - reject_delivery_type, - end_time: est.end_time || est.dueDate || '미지정', - url: '', - }; - }); -} - // ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ────────────── // 세션상태 코드→라벨. 서버 /v1/enums(session_status) · SHARED_ENUMS.md 5-state 와 동일해야 한다. // (정본은 서버 enum — 여기 값은 그걸 미러링한 것이며 드리프트 시 서버 기준으로 맞춘다.) -export const SESSION_STATUS_LABEL: Record = { - 1: '협상생성', - 2: '협상중', - 3: '협상완료', - 4: '미참여', - 5: '협상거부', +export const SESSION_STATUS_LABEL: Record = { + [SessionStatus.CREATED]: '협상생성', + [SessionStatus.IN_PROGRESS]: '협상중', + [SessionStatus.DONE]: '협상완료', + [SessionStatus.NOT_PARTICIPATED]: '미참여', + [SessionStatus.REJECTED]: '협상거부', }; // 코드→라벨 단일 진입점. 미정의 코드는 코드 문자열 그대로. export const sessionStatusLabel = (code?: number | null): string => - (code != null ? SESSION_STATUS_LABEL[code] : undefined) ?? String(code ?? ''); -const DELIVERY_TYPE_LABEL: Record = { 1: '협력사배송', 2: '지정택배배송', 3: '픽업배송' }; + (code != null ? SESSION_STATUS_LABEL[code as SessionStatus] : undefined) ?? String(code ?? ''); // ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'. export function fmtDateTime(s?: string | null): string { @@ -294,7 +211,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ supplier_name: supplier?.name || sd.supplier_id, item_id: sd.item_id, item_name: product?.name || '부품', - status: SESSION_STATUS_LABEL[sd.status] || String(sd.status), + status: sd.status, target_price: sd.target_price ?? 0, bid_price: sd.bid_price ?? null, bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-', @@ -314,7 +231,7 @@ export function mapServerCardView(c: QuotationCardData): QuotationCardView { session_card_id: c.session_card_id, card_id: c.nego_card_id ?? c.wild_card_id ?? null, card_name: c.name || '-', - type: c.type === 2 ? '와일드 카드' : '협상 카드', + type: c.type === CardType.WILD ? '와일드 카드' : '협상 카드', }; } diff --git a/negodata/front/src/lib/enumLabels.ts b/negodata/front/src/lib/enumLabels.ts new file mode 100644 index 0000000..5d4aff1 --- /dev/null +++ b/negodata/front/src/lib/enumLabels.ts @@ -0,0 +1,16 @@ +import { DeliveryType, UserRole } from '@/api/generated/model'; + +export const DELIVERY_TYPE_LABEL: Record = { + [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.USER]: '일반', + [UserRole.MANAGER]: '관리자', +}; diff --git a/negodata/front/src/lib/useOverlayParams.ts b/negodata/front/src/lib/useOverlayParams.ts deleted file mode 100644 index b234c62..0000000 --- a/negodata/front/src/lib/useOverlayParams.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useSearchParams } from 'react-router'; - -// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처. -// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다. -// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거). -// -// const overlay = useOverlayParams(['edit', 'new', 'modal']); -// overlay.get('edit') // ?edit= 의 값(없으면 null) — 값 있는 오버레이 -// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이 -// overlay.open('edit', id) // ?edit= (다른 오버레이 키는 지움) -// overlay.open('new') // ?new (값 생략 시 '1') -// overlay.close() // 그룹 내 모든 오버레이 키 제거 -export function useOverlayParams(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 }; -} diff --git a/negodata/front/src/lib/useOverlayRouter.ts b/negodata/front/src/lib/useOverlayRouter.ts new file mode 100644 index 0000000..b82f43e --- /dev/null +++ b/negodata/front/src/lib/useOverlayRouter.ts @@ -0,0 +1,50 @@ +import { useLocation, useNavigate, useSearchParams } from 'react-router'; + +// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처. +// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다. +// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거). +// +// const overlay = useOverlayRouter(['detail', 'new', 'modal']); +// overlay.get('detail') // ?detail= 의 값(없으면 null) — 값 있는 오버레이 +// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이 +// overlay.open('detail', id) // ?detail= (다른 오버레이 키는 지움) — 히스토리 push +// overlay.open('new') // ?new (값 생략 시 '1') +// overlay.close() // 그룹 내 모든 오버레이 키 제거 +const OVERLAY_PUSHED = '__overlayPushed'; + +export function useOverlayRouter(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 }; +} diff --git a/negodata/front/src/lib/useServerList.ts b/negodata/front/src/lib/useServerList.ts index ac8daae..f6f3b60 100644 --- a/negodata/front/src/lib/useServerList.ts +++ b/negodata/front/src/lib/useServerList.ts @@ -1,13 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; // 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처. // 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로 -// 쿼리 파라미터를 만들어 수행한다 — 이 훅은 패칭을 하지 않고 상태만 관리한다. -// -// - search: 입력 즉시 반영(controlled) + debouncedSearch(쿼리용, 기본 300ms)로 분리해 -// 키 입력마다 서버를 때리지 않는다. -// - filters: 임의 키-값(category/priority 등). 'ALL' 같은 "전체" 값의 의미는 -// 호출부가 파라미터를 만들 때 결정한다(여기선 단순 보관). // - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지). export type ServerListControls = { page: number; @@ -15,6 +9,7 @@ export type ServerListControls = { pageSize: number; search: string; // input value (controlled) setSearch: (v: string) => void; + submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사) debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용) filters: Record; setFilter: (key: string, value: string) => void; @@ -25,25 +20,39 @@ export function useServerList(opts?: { pageSize?: number; initialFilters?: Record; debounceMs?: number; + minSearchLength?: number; }): ServerListControls { const pageSize = opts?.pageSize ?? 10; - const debounceMs = opts?.debounceMs ?? 300; + const debounceMs = opts?.debounceMs ?? 500; + const minSearchLength = opts?.minSearchLength ?? 2; const [page, setPage] = useState(1); const [search, setSearchInput] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [filters, setFilters] = useState>(() => opts?.initialFilters ?? {}); + const timerRef = useRef | undefined>(undefined); - // 입력 디바운스 → 쿼리용 검색어 + // 입력 디바운스 → 쿼리용 검색어. + // 최소 길이 미만은 빈 검색(전체)으로 둬서 1글자 스캔 요청이 서버로 나가지 않게 막는다. useEffect(() => { - const t = setTimeout(() => setDebouncedSearch(search.trim()), debounceMs); - return () => clearTimeout(t); - }, [search, debounceMs]); + timerRef.current = setTimeout(() => { + const q = search.trim(); + setDebouncedSearch(q.length >= minSearchLength ? q : ''); + }, debounceMs); + return () => clearTimeout(timerRef.current); + }, [search, debounceMs, minSearchLength]); const setSearch = (v: string) => { setSearchInput(v); setPage(1); }; + + // 엔터: 대기 중인 디바운스 타이머를 버리고 최소길이 무시하고 즉시 1회 발사(의도적 검색). + const submitSearch = () => { + clearTimeout(timerRef.current); + setDebouncedSearch(search.trim()); + setPage(1); + }; const setFilter = (key: string, value: string) => { setFilters((f) => ({ ...f, [key]: value })); setPage(1); @@ -51,5 +60,5 @@ export function useServerList(opts?: { const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize)); - return { page, setPage, pageSize, search, setSearch, debouncedSearch, filters, setFilter, totalPages }; + return { page, setPage, pageSize, search, setSearch, submitSearch, debouncedSearch, filters, setFilter, totalPages }; } diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index 27ac37c..05afe62 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -1,33 +1,43 @@ import { Plus, BookOpen } from 'lucide-react'; -import { useOverlayParams } from '@/lib/useOverlayParams'; +import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { PageContainer } from '@/components/layout/PageContainer'; import { SearchInput } from '@/components/layout/PageToolbar'; import { TablePagination } from '@/components/ui/table-pagination'; import { Typography } from '@/components/ui/typography'; -import { useClientPagination } from '@/lib/useClientPagination'; +import { useServerList } from '@/lib/useServerList'; import { useCards } from '@/features/cards/hooks/useCards'; -import { useCardFilters } from '@/features/cards/hooks/useCardFilters'; +import { useGetCard } from '@/api/generated/card/card'; import { CardTable } from '@/features/cards/components/CardTable'; import { CardFormSheet } from '@/features/cards/components/CardFormSheet'; -import type { CardTab, NegotiationCard } from '@/features/cards/types'; +import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types'; +import type { ListCardsParams } from '@/api/generated/model/listCardsParams'; export default function CardsPage() { - const { cards, createCard, updateCard, deleteCard } = useCards(); - const { search, setSearch, activeTab, setActiveTab, filtered, counts } = useCardFilters(cards); - const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered); + // 검색/탭/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환. + const list = useServerList({ pageSize: 10, initialFilters: { tab: 'ALL' } }); + const activeTab = list.filters.tab as CardTab; + const params: ListCardsParams = { + search: list.debouncedSearch || undefined, + is_wildcard: activeTab === 'ALL' ? undefined : activeTab === 'WILD', + page: list.page, + size: list.pageSize, + }; + const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard } = useCards(params); + const totalPages = list.totalPages(total); // 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원. - // ?edit= 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다. - const overlay = useOverlayParams(['new', 'edit']); - const editId = overlay.get('edit'); - const editing = editId ? cards.find((c) => c.id === editId) ?? null : null; + // ?detail= 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작). + const overlay = useOverlayRouter(['new', 'detail']); + const editId = overlay.get('detail'); + const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } }); + const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null; const formMode: 'create' | 'edit' = editId ? 'edit' : 'create'; const isFormOpen = overlay.has('new') || !!editing; const openCreate = () => overlay.open('new'); - const openEdit = (card: NegotiationCard) => overlay.open('edit', card.id); + const openEdit = (card: NegotiationCard) => overlay.open('detail', card.id); const handleDeleteCard = async (id: string, cardName: string) => { if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) { @@ -41,9 +51,9 @@ export default function CardsPage() { }; const tabs: { id: CardTab; label: string; count: number }[] = [ - { id: 'ALL', label: '전체', count: counts.all }, - { id: 'CARD', label: '협상카드', count: counts.card }, - { id: 'WILD', label: '와일드카드', count: counts.wild }, + { id: 'ALL', label: '전체', count: totalNego + totalWild }, + { id: 'CARD', label: '협상카드', count: totalNego }, + { id: 'WILD', label: '와일드카드', count: totalWild }, ]; return ( @@ -76,7 +86,7 @@ export default function CardsPage() { } > setSearch(e.target.value)} - placeholder="견적명 또는 견적 번호로 실시간 서치..." + value={list.search} + onChange={(e) => list.setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()} + placeholder="견적명 또는 견적 번호로 검색..." />
- list.setFilter('status', v as string)}> - + + {(value) => + value === 'ALL' + ? '전체 견적상태' + : statusOptions.find((o) => String(o.value) === value)?.label ?? '' + } + 전체 견적상태 - {QUOTATION_STATUS_FILTERS.map((s) => ( - {s} + {statusOptions.map((o) => ( + {o.label} ))} - list.setFilter('type', v as string)}> - {(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')} + {(value) => + value === 'ALL' + ? '전체 유형' + : typeOptions.find((o) => String(o.value) === value)?.label ?? '' + } 전체 유형 - 재협상 - 재견적 + {typeOptions.map((o) => ( + {o.label} + ))}
overlay.open('detail', id)} footer={ @@ -117,19 +142,16 @@ export default function QuotationPage() { /> {activeQuotation && ( - )} {isCreateOpen && ( - & { - id?: string; // item_id back-compatibility map - minPrice?: number; // UI minimum reserve limit - status?: string; // UI lifecycle state +export type Product = ItemData & { + deleted?: boolean; + id?: string; + minPrice?: number; + status?: string; }; -export type Partner = Partial & { - id?: string; // supplier_id back-compatibility map - managerName?: string; // manager_name - managerEmail?: string; // manager_email - managerPhone?: string; // manager_contact_number - manager_contact_number?: string; // DB 컬럼 직접 매핑(철자 정상) - rank?: 'A' | 'B' | 'C' | 'S'; // computed from priority - status?: string; // mapped to deleted - memo?: string; // back-compatible details +export type Partner = SupplierData & { + deleted?: boolean; + id?: string; + managerName?: string; + managerEmail?: string; + managerPhone?: string; + rank?: 'A' | 'B' | 'C' | 'S'; + status?: string; + memo?: string; }; -export type Estimate = Partial & { - id?: string; // qt_id back-compatibility map - dueDate?: string; // end_time - title?: string; // mapped to name in UI - productId?: string; // mapped to association - productName?: string; // 서버 목록 조인 상품명(products 목록에 없을 때 폴백) - partnerIds?: string[]; // mapped B2B suppliers - participationCount?: number; - winnerPartnerId?: string | null; - finalPrice?: number; - isEqualPrice?: boolean; - usedCardIds?: string[]; - settingApplied?: boolean | string; -}; - -// UI Chat Message definition (corresponds to in-memory/rendered chats) -export interface ChatMessage { - id: string; - sender: 'BOT' | 'PARTNER' | 'SYSTEM'; - timestamp: string; - content: string; - editorScript?: any; // Slate JSON structure -} - -export interface ChatSession { - id: string; // matches supplier_id (or supplier.supplier_id) - partnerName: string; - status: 'NEGOTIATING' | 'COMPLETED' | 'REJECTED' | '협상생성' | '협상중' | '협상완료' | '미참여' | '협상거부'; - currentBid: number; - bidTime: string; - messages: ChatMessage[]; -} - export interface NegotiationCard { - id: string; // nego_card_id or wild_card_id - isWildcard: boolean; // mapping based on source table - code: string; // number (식별번호) or custom code - title: string; // name - scriptPreview: string; // script - editorScript: any; // edit_script (JSON) + id: string; + isWildcard: boolean; + code: string; + title: string; + scriptPreview: string; + editorScript: any; status: 'ACTIVE' | 'INACTIVE'; - triggerCondition?: string; // wild_card's condition - memo?: string; // wild_card's memo + triggerCondition?: string; + memo?: string; } export type PageType = 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS';