diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index c1e5ea2..6418d31 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -58,6 +58,21 @@ class suppliers(MAIN_BASE): deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 +class companies(MAIN_BASE): + # company.companies (고객사). 공급사 포털 브랜딩(settings.branding) 조회 전용 미러. + @staticmethod + def DBType(): + return DBType.PARTNER.value + + __tablename__ = "companies" + __table_args__ = {"schema": "company"} + + company_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 회사 식별자(PK) + name = Column(String(100), nullable=False) # 회사명 + settings = Column(JSONB, nullable=True) # 회사별 커스터마이징(branding/labels 등, negodata 소유) + deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 + + class items(MAIN_BASE): # partner.items (상품). @staticmethod @@ -123,6 +138,7 @@ class sessions(MAIN_BASE): reject_reason = Column(String(255), nullable=True) # 거절 사유 reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원) reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드) + custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -183,6 +199,20 @@ class quotation_settings(MAIN_BASE): deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 +class nego_cards(MAIN_BASE): + # card.nego_cards (협상카드). backend 는 번호→UUID 변환(chats.card_id 저장)만 위해 최소 컬럼 미러. + @staticmethod + def DBType(): + return DBType.NEGOTIATION.value # 같은 negosium_db — chats 와 동일 세션풀로 조회 + + __tablename__ = "nego_cards" + __table_args__ = {"schema": "card"} + + nego_card_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) + number = Column(String(10), nullable=True) # 카드 번호(agent turn.card_id 와 매칭) + deleted = Column(Boolean, nullable=False, server_default=text("false")) + + class chats(MAIN_BASE): # negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크. @staticmethod diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index 05d8678..6e36228 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import chats, items, sessions +from common.database.model.models import chats, items, sessions, nego_cards from common.enums import ErrorType, SessionStatus from common.logger import LOG @@ -47,6 +47,10 @@ class IChatCRUD(ABC): async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: pass + @abstractmethod + async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str): + pass + class ChatCRUD(IChatCRUD): async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: @@ -112,6 +116,18 @@ class ChatCRUD(IChatCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str): + # 협상카드 번호(agent turn.card_id) → nego_card_id(UUID). 없으면 None. 카드 사용 로그(chats.card_id) 저장용. + try: + query = select(nego_cards.nego_card_id).where(nego_cards.number == number, nego_cards.deleted == False).limit(1) # noqa: E712 + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_nego_card_id_by_number({number}) failed.") + if err_type != ErrorType.SUCCESS or not row_list: + return None + return row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return None + async def finalize_session( self, cdb: AsyncSession, session_id, status: int, bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None, diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index 0e0c22e..9059ca0 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -41,6 +41,10 @@ class ISessionCRUD(ABC): async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType: pass + @abstractmethod + async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType: + pass + class SessionCRUD(ISessionCRUD): @staticmethod @@ -86,6 +90,7 @@ class SessionCRUD(ISessionCRUD): items.name, items.model_name, items.manufacturer, + sessions.custom, ) .join(items, items.item_id == sessions.item_id) .join(quotations, quotations.qt_id == sessions.quotation_id) @@ -173,3 +178,16 @@ class SessionCRUD(ISessionCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType: + # 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드). + try: + query = ( + update(sessions) + .where(sessions.session_id == session_id, sessions.supplier_id == supplier_id) + .values(custom=custom) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/backend/crud/user_crud.py b/backend/crud/user_crud.py index 21eb679..04db9a7 100644 --- a/backend/crud/user_crud.py +++ b/backend/crud/user_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import supplier_user_tokens, supplier_users, suppliers +from common.database.model.models import supplier_user_tokens, supplier_users, suppliers, companies from common.enums import ErrorType, TokenType from common.logger import LOG from common.utils.gtime import GTime @@ -28,6 +28,10 @@ class IUserCRUD(ABC): async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]: pass + @abstractmethod + async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]: + pass + @abstractmethod async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType: pass @@ -117,6 +121,25 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]: + """공급사 소속 회사 설정(companies.settings) 전체. 브랜딩·협상완료 필드 등이 들어있다. 미설정이면 빈 dict.""" + try: + query = ( + select(companies.settings) + .join(suppliers, suppliers.company_id == companies.company_id) + .where(suppliers.supplier_id == supplier_id, suppliers.deleted == False, companies.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_company_settings(supplier_id:{supplier_id}) failed.") + if err_type != ErrorType.SUCCESS: + return err_type, {} + settings = row_list[0] if row_list else None + return ErrorType.SUCCESS, settings or {} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + + async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType: try: query = ( diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index d642957..4367aa1 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -48,6 +48,8 @@ class Res_Me(Res_WebPacketProtocol): supplier_id: str = Field("", description="소속 공급사 uuid") supplier_name: str = Field("", description="공급사명") role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)") + branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색") + session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력") class Res_Logout(Res_WebPacketProtocol): diff --git a/backend/router/v1/chat/protocol.py b/backend/router/v1/chat/protocol.py index 71ba77c..cdc1892 100644 --- a/backend/router/v1/chat/protocol.py +++ b/backend/router/v1/chat/protocol.py @@ -77,6 +77,7 @@ class Res_ChatInit(Res_WebPacketProtocol): item_min_order_quantity: str = Field("", description="최소 주문 수량") item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)") item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)") + custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용") # 대화 히스토리(재진입 복원) diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 5908855..e1dba6e 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -14,6 +14,7 @@ class ListItem(WebPacketProtocol): item_name: str = Field("", description="상품명") model_name: str = Field("", description="모델명") maker_name: str = Field("", description="제조사") + custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict") class Res_SessionList(Res_WebPacketProtocol): @@ -33,3 +34,11 @@ class Req_Reject(WebPacketProtocol): class Res_Reject(Res_WebPacketProtocol): session_id: str = Field("", description="거부 처리된 세션 uuid") + + +class Req_ExtraInfo(WebPacketProtocol): + custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값 {key: value} (회사 정의 session_fields 대로)") + + +class Res_ExtraInfo(Res_WebPacketProtocol): + session_id: str = Field("", description="부가정보 저장된 세션 uuid") diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 86a6de3..323d85d 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security from services.negotiation_service import NegotiationService -from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList +from .protocol import Req_ExtraInfo, Req_Reject, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}}) @@ -61,3 +61,19 @@ async def reject( service: NegotiationService = Depends(), ): return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason)) + + +@router.post( + path="/sessions/{session_id}/extra-info", + response_model=Res_ExtraInfo, + summary="협상완료 부가정보 저장", + description="협상 타결(완료) 세션에 부가정보(표준납기/MOQ/발주배수/배송유형 등, 회사 정의 session_fields)를 저장한다. 본인 공급사의 완료 세션만 허용.", +) +async def save_extra_info( + session_id: str = Path(description="대상 협상 세션 uuid"), + req: Req_ExtraInfo = ..., + user_info: UserInfo = Depends(IsValidAccessToken), + credentials: HTTPAuthorizationCredentials = Depends(security), + service: NegotiationService = Depends(), +): + return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req)) diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 2b0a829..d06511d 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -250,6 +250,15 @@ class AuthService: res.supplier_id = info.supplier_id res.supplier_name = info.supplier_name res.role = info.role + # 소속 회사 설정(companies.settings) — 로고/서비스명(branding) + 협상완료 부가필드(session_fields). 실패해도 기본값. + _e, settings = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_company_settings(s, uuid.UUID(info.supplier_id)), + ) + settings = settings or {} + res.branding = settings.get("branding") or {} + res.session_fields = settings.get("session_fields") or [] return res async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus: diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index ac6ab27..d434f0d 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -103,13 +103,18 @@ class ChatService: ) @staticmethod - def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats: + def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None, nego_card_uuid=None) -> chats: # bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재. + # nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재 + # → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업) return chats( chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, sender=ChatSender.BOT.value, target_price=int(sess.target_price or 0), indicator_value=turn.indicator_value, + card_id=nego_card_uuid, + card_type=1 if nego_card_uuid else None, # CardType: 1=nego (wild=2 는 별도) + card_used_yn=True if nego_card_uuid else None, meta={ "script": turn.script, "step": turn.step, "client_step": turn.client_step, "input_mode": turn.input_mode, "input_options": turn.input_options, @@ -221,6 +226,7 @@ class ChatService: res.item_min_order_quantity = item.moq or "" res.item_vat_yn = item.vat_yn res.item_delivery_fee_yn = item.delivery_fee_yn + res.custom = sess.custom or {} return res # ---- messages ------------------------------------------------------- @@ -371,8 +377,16 @@ class ChatService: # 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다). summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price) + # nego 카드 번호(turn.card_id) → UUID 변환. 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. + nego_card_uuid = None + if turn.card_id: + nego_card_uuid = await DB_SESSION_MNG.execute_lambda( + chats.DBType(), DBWRType.DB_READ.value, + lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)), + ) + # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. - bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) + bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, nego_card_uuid=nego_card_uuid) funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)] # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py index f551bf2..5f6c1b3 100644 --- a/backend/services/negotiation_service.py +++ b/backend/services/negotiation_service.py @@ -8,7 +8,7 @@ from common.database.model.models import sessions from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus from common.models.gmodel import UserInfo from crud.session_crud import ISessionCRUD, SessionCRUD -from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList +from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList from services.auth_service import AuthService @@ -65,6 +65,7 @@ class NegotiationService: item_name=r[6] or "", model_name=r[7] or "", maker_name=r[8] or "", + custom=r[9] or {}, ) for r in rows ] @@ -73,6 +74,53 @@ class NegotiationService: res.page_size = page_size return res + async def save_extra_info(self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_ExtraInfo) -> Res_ExtraInfo: + """협상완료(타결) 부가정보 저장. 견적 마감 여부와 무관하게, 본인 공급사의 '협상완료' 세션에만 허용. + + _load_actionable_session 은 견적마감·마감시간을 막으므로(타결 후엔 마감됐을 수 있음) 쓰지 않고 직접 검증한다. + """ + res = Res_ExtraInfo() + + # 1) 인증 + err_type, info = await self.auth.authenticate(user_info, access_token) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + try: + session_id = uuid.UUID(session_id_str) + except (ValueError, TypeError): + res.result.SetResult(ErrorType.NEGO_NOT_FOUND) + return res + + # 2) 세션 조회 + 소유 검증 + err_type, sess = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), DBWRType.DB_READ.value, + lambda s: self.session_crud.get_session_by_id(s, session_id), + ) + if err_type != ErrorType.SUCCESS or sess is None: + res.result.SetResult(ErrorType.NEGO_NOT_FOUND) + return res + if str(sess.supplier_id) != info.supplier_id: + res.result.SetResult(ErrorType.NEGO_FORBIDDEN) + return res + + # 3) 협상완료(타결) 세션만 부가정보 입력 허용 + if sess.status != SessionStatus.DONE.value: + res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) + return res + + # 4) 저장(supplier_id 가드 crud) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [sessions.DBType()], + [lambda s: self.session_crud.update_session_custom(s, session_id, uuid.UUID(info.supplier_id), req.custom or {})], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + res.session_id = str(session_id) + return res + async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple): """참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증. 성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다. diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts index cc0e02d..ba607bd 100644 --- a/frontend/src/apis/auth/auth.type.ts +++ b/frontend/src/apis/auth/auth.type.ts @@ -54,6 +54,20 @@ export interface RefreshTokenResponse { } // --- 내 정보 (GET /v1/auth/me) ------------------------------------------- +export interface Branding { + service_name?: string + logo_url?: string + primary_color?: string + email_header?: string +} + +// 협상완료 부가정보 필드 정의(companies.settings.session_fields) +export interface SessionField { + key: string + label: string + type: 'text' | 'number' | 'boolean' +} + export interface MeResponse { result: ApiResult su_id: string @@ -62,6 +76,8 @@ export interface MeResponse { supplier_id: string supplier_name: string role: number + branding?: Branding + session_fields?: SessionField[] } // --- 로그아웃 ------------------------------------------------------------- @@ -94,6 +110,8 @@ export interface AuthUser { supplierId: string supplierName: string role: number + branding: Branding + sessionFields: SessionField[] } export function toAuthUser(res: MeResponse): AuthUser { @@ -104,5 +122,7 @@ export function toAuthUser(res: MeResponse): AuthUser { supplierId: res.supplier_id, supplierName: res.supplier_name, role: res.role, + branding: res.branding ?? {}, + sessionFields: res.session_fields ?? [], } } diff --git a/frontend/src/apis/chat/chat.type.ts b/frontend/src/apis/chat/chat.type.ts index 0455fa5..1f76d03 100644 --- a/frontend/src/apis/chat/chat.type.ts +++ b/frontend/src/apis/chat/chat.type.ts @@ -46,6 +46,7 @@ export interface ChatInitResponse { item_min_order_quantity?: string item_vat_yn?: boolean item_delivery_fee_yn?: boolean + custom?: Record } export interface ChatMessagesResponse { @@ -98,6 +99,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData { item_delivery_fee_yn: r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도', item_min_order_quantity: r.item_min_order_quantity ?? '', + custom: r.custom ?? {}, item_lead_time: r.item_lead_time ?? '', item_spec: r.item_spec ?? '', quotation_memo: r.quotation_memo ?? '', diff --git a/frontend/src/apis/negotiation/index.ts b/frontend/src/apis/negotiation/index.ts index f82d7e4..49deade 100644 --- a/frontend/src/apis/negotiation/index.ts +++ b/frontend/src/apis/negotiation/index.ts @@ -2,5 +2,5 @@ export { negotiationApi } from './negotiation.api' export { negotiationKeys } from './negotiation.keys' export { useSessionListQuery } from './negotiation.queries' -export { useParticipateMutation, useRejectMutation } from './negotiation.mutations' +export { useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from './negotiation.mutations' export * from './negotiation.type' diff --git a/frontend/src/apis/negotiation/negotiation.api.ts b/frontend/src/apis/negotiation/negotiation.api.ts index 0f78a67..2e430c6 100644 --- a/frontend/src/apis/negotiation/negotiation.api.ts +++ b/frontend/src/apis/negotiation/negotiation.api.ts @@ -1,6 +1,8 @@ // 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음). import { http } from '@/apis/http' import type { + ExtraInfoRequest, + ExtraInfoResponse, ParticipateResponse, RejectRequest, RejectResponse, @@ -31,4 +33,13 @@ export const negotiationApi = { ) return res.data }, + + /** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */ + saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/extra-info`, + body, + ) + return res.data + }, } diff --git a/frontend/src/apis/negotiation/negotiation.mutations.ts b/frontend/src/apis/negotiation/negotiation.mutations.ts index 947565f..10030a4 100644 --- a/frontend/src/apis/negotiation/negotiation.mutations.ts +++ b/frontend/src/apis/negotiation/negotiation.mutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { negotiationApi } from './negotiation.api' import { negotiationKeys } from './negotiation.keys' -import type { RejectRequest } from './negotiation.type' +import type { ExtraInfoRequest, RejectRequest } from './negotiation.type' /** * 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다. @@ -30,3 +30,17 @@ export function useRejectMutation() { }, }) } + +/** + * 협상완료 부가정보 저장: 성공 시 세션 목록 캐시를 무효화한다. + */ +export function useSaveExtraInfoMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ sessionId, request }: { sessionId: string; request: ExtraInfoRequest }) => + negotiationApi.saveExtraInfo(sessionId, request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) + }, + }) +} diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts index c54b48a..ccd0fd5 100644 --- a/frontend/src/apis/negotiation/negotiation.type.ts +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -55,6 +55,7 @@ export interface SessionListItem { item_name: string model_name: string maker_name: string + custom: Record // 협상완료 부가정보(sessions.custom). 미입력이면 {} } export interface SessionListResponse { @@ -65,6 +66,15 @@ export interface SessionListResponse { page_size: number } +// --- 협상완료 부가정보 (POST /v1/negotiation/sessions/{id}/extra-info) ----- +export interface ExtraInfoRequest { + custom: Record +} +export interface ExtraInfoResponse { + result: ApiResult + session_id: string +} + // --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ---------------- export interface ParticipateResponse { result: ApiResult diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index f4efe89..ee6b3db 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -22,6 +22,9 @@ export interface LogoProps extends Omit, 'children'> { size?: LogoSize withText?: boolean alt?: string + /** 회사 브랜딩 오버라이드(companies.settings.branding). 없으면 기본 iMarket Korea. */ + serviceName?: string + logoUrl?: string } export function Logo({ @@ -29,21 +32,24 @@ export function Logo({ size = 'md', withText = true, alt = 'iMarket Korea', + serviceName, + logoUrl, className, ...props }: LogoProps) { const s = sizes[size] + const name = serviceName || 'iMarket Korea' return (
{withText {withText && ( - iMarket Korea + {name} )}
diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index 281c44d..a046996 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -6,6 +6,7 @@ import type { ChatMessage as ChatMessageType } from '@/features/chat/types' import { renderEmphasis } from '@/features/chat/lib/emphasis' import { Indicator } from '@/features/chat/components/templates/Indicator' import { Summary } from '@/features/chat/components/templates/Summary' +import { ExtraInfoForm } from '@/features/chat/components/templates/ExtraInfoForm' import { BidSummary } from '@/features/chat/components/templates/BidSummary' import { RejectRSP } from '@/features/chat/components/templates/RejectRSP' import { RejectCM } from '@/features/chat/components/templates/RejectCM' @@ -108,7 +109,12 @@ const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageT {showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && ( )} - {message.bot_chat_type === 'summaryRSP' && message.summary && } + {message.bot_chat_type === 'summaryRSP' && message.summary && ( + <> + + + + )} {message.bot_chat_type === 'summaryCM' && message.summary && ( s.sessionId) + const { data: user } = useMeQuery() + const fields: SessionField[] = user?.sessionFields ?? [] + const save = useSaveExtraInfoMutation() + const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필) + + const [values, setValues] = useState>({}) + const [inited, setInited] = useState(false) + const [saved, setSaved] = useState(false) + + // fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존. + useEffect(() => { + if (inited || fields.length === 0) return + const init: Record = {} + for (const f of fields) init[f.key] = existing?.[f.key] ?? (f.type === 'boolean' ? false : '') + setValues(init) + setInited(true) + }, [inited, fields, existing]) + + if (fields.length === 0) return null + + const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value })) + + const handleSave = () => { + const custom: Record = {} + for (const f of fields) { + const v = values[f.key] + if (f.type === 'boolean') custom[f.key] = !!v + else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v + } + save.mutate( + { sessionId, request: { custom } }, + { + onSuccess: () => { + setSaved(true) + toast.success('부가정보가 저장되었습니다.') + }, + onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')), + }, + ) + } + + return ( +
+
+ +

부가정보 입력

+
+

+ 협상이 완료되었습니다. 아래 정보를 입력해 주세요. (저장 후에도 세션 목록에서 수정할 수 있습니다.) +

+ +
+ {fields.map((f) => ( +
+ + {f.type === 'boolean' ? ( + + ) : ( + set(f.key, e.target.value)} + disabled={saved} + className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:bg-neutral-10" + placeholder={f.label} + /> + )} +
+ ))} +
+ + +
+ ) +} diff --git a/frontend/src/features/chat/stores/useChatInitStore.ts b/frontend/src/features/chat/stores/useChatInitStore.ts index 6f23dc3..30a82af 100644 --- a/frontend/src/features/chat/stores/useChatInitStore.ts +++ b/frontend/src/features/chat/stores/useChatInitStore.ts @@ -29,6 +29,7 @@ const initialState: ChatInitData = { item_spec: '', quotation_memo: '', quotation_end_time: '', + custom: {}, } export const useChatInitStore = create((set) => ({ diff --git a/frontend/src/features/chat/types.ts b/frontend/src/features/chat/types.ts index 86ec723..691ae2f 100644 --- a/frontend/src/features/chat/types.ts +++ b/frontend/src/features/chat/types.ts @@ -81,4 +81,5 @@ export type ChatInitData = { item_spec: string quotation_memo: string quotation_end_time: string + custom: Record // 협상완료 부가정보 기존 입력값(프리필용) } diff --git a/frontend/src/features/list/components/ExtraInfoPopup.tsx b/frontend/src/features/list/components/ExtraInfoPopup.tsx new file mode 100644 index 0000000..1613531 --- /dev/null +++ b/frontend/src/features/list/components/ExtraInfoPopup.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { X } from 'lucide-react' +import { Modal } from '@/components' +import { useMeQuery } from '@/apis' +import type { SessionField } from '@/apis/auth/auth.type' +import type { ListItem } from '../types' + +export interface ExtraInfoPopupProps { + target: ListItem + onClose: () => void + /** 부가정보 저장 (custom = {key: value}) */ + onSubmit: (custom: Record) => void +} + +// 협상완료 부가정보 입력 팝업. 필드 정의(session_fields)는 회사 설정(/me)에서 오고, 기존 입력값(target.custom)으로 프리필. +export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProps) { + const { data: user } = useMeQuery() + const fields: SessionField[] = user?.sessionFields ?? [] + + const [values, setValues] = useState>(() => { + const init: Record = {} + for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '') + return init + }) + + const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value })) + + const handleSubmit = () => { + // 빈 값은 제외하고 저장(부분 입력 허용) + const out: Record = {} + for (const f of fields) { + const v = values[f.key] + if (f.type === 'boolean') out[f.key] = !!v + else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v + } + onSubmit(out) + onClose() + } + + return ( + +
+
+
+

협상완료 부가정보

+

{target.qt_number} · {target.item_name}

+
+ +
+ +
+ {fields.length === 0 ? ( +

입력할 부가정보 항목이 없습니다.

+ ) : ( + fields.map((f) => ( +
+ + {f.type === 'boolean' ? ( + + ) : ( + set(f.key, e.target.value)} + className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600" + placeholder={f.label} + /> + )} +
+ )) + )} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/list/components/WorkspaceCards.tsx b/frontend/src/features/list/components/WorkspaceCards.tsx index 406dc2f..07e43de 100644 --- a/frontend/src/features/list/components/WorkspaceCards.tsx +++ b/frontend/src/features/list/components/WorkspaceCards.tsx @@ -9,10 +9,11 @@ interface WorkspaceCardsProps { busyId?: string | null onEnter: (item: ListItem) => void onReject: (item: ListItem) => void + onExtraInfo: (item: ListItem) => void } // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. -export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: WorkspaceCardsProps) { +export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) { if (isLoading) { return (
@@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: return (
{items.map((item) => ( - + ))}
) @@ -38,25 +39,26 @@ function Card({ busy, onEnter, onReject, + onExtraInfo, }: { item: ListItem busy: boolean onEnter: (item: ListItem) => void onReject: (item: ListItem) => void + onExtraInfo: (item: ListItem) => void }) { const meta = statusMeta(item.session_status) const canEnter = !['미참여', '협상거부'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status) - const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장' + const isDone = item.session_status === '협상완료' + const enterLabel = isDone ? '결과 보기' : '협상 입장' + const hasExtra = item.custom && Object.keys(item.custom).length > 0 return (
- - {item.qt_type || '-'} - {item.qt_number || '-'}

{item.item_name || '-'}

@@ -76,8 +78,17 @@ function Card({ 마감 {formatKstDateTime(item.qt_end_time)}
- {(canEnter || canReject) && ( + {(canEnter || canReject || isDone) && (
+ {isDone && ( + + )} {canReject && (
+ ); +} + +function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { + return ( +
+ {title} + {desc} + {children} +
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} + {hint && {hint}} +
+ ); +} + +// 커스텀필드 정의 편집 — 표시명을 입력하면 key 를 자동 제안하되 직접 수정도 가능. +function CustomFieldsEditor({ + title, + desc, + fields, + onChange, +}: { + title: string; + desc: string; + fields: CustomFieldDef[]; + onChange: (fields: CustomFieldDef[]) => void; +}) { + const update = (i: number, patch: Partial) => + onChange(fields.map((f, idx) => (idx === i ? { ...f, ...patch } : f))); + + return ( + +
+ + + + 표시명 + 키 (영문) + 유형 + 삭제 + + + + {fields.length === 0 && ( + + + 추가된 커스텀 필드가 없습니다. + + + )} + {fields.map((f, i) => ( + + + update(i, { label: e.target.value })} + placeholder="예: 발주배수" + /> + + + update(i, { key: sanitizeKey(e.target.value) })} + placeholder="예: order_multiple" + /> + + + + value={f.type} + onValueChange={(v) => v && update(i, { type: v })} + > + + + + + {(Object.keys(CUSTOM_FIELD_TYPE_LABEL) as CustomFieldType[]).map((t) => ( + + {CUSTOM_FIELD_TYPE_LABEL[t]} + + ))} + + + + + + + + ))} + +
+
+
+ +
+
+ ); +} + +// key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화). +function sanitizeKey(raw: string): string { + return raw.toLowerCase().replace(/[^a-z0-9_]/g, ''); +} diff --git a/negodata/front/src/features/settings/catalog.ts b/negodata/front/src/features/settings/catalog.ts new file mode 100644 index 0000000..92190bc --- /dev/null +++ b/negodata/front/src/features/settings/catalog.ts @@ -0,0 +1,51 @@ +// 회사 커스터마이징 설정(companies.settings JSONB) 문서 타입 + 용어 라벨 카탈로그. +// 라벨 키는 여기 한 곳에만 추가한다 — 설정 화면(용어 탭)과 화면 배선(useLabel)이 같은 카탈로그를 읽는다. + +export type CustomFieldType = 'text' | 'number' | 'boolean'; + +export type CustomFieldDef = { + key: string; // custom JSONB 의 키 (영문 snake_case) + label: string; // 화면 표시명 + type: CustomFieldType; +}; + +export type CompanySettings = { + branding?: { + service_name?: string; // 사이드바/타이틀 서비스명 (기본 NegoData) + logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트 + primary_color?: string; // 브랜드 색 (hex) + email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA) + }; + labels?: Record; // 카탈로그 키 → 이 회사 용어 (없으면 기본값) + features?: Record; // 회사별 동작 플래그 (예: target_price_mode) + item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom + supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom + session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력) +}; + +export type LabelCatalogEntry = { + key: string; + base: string; // 기본(우리 솔루션) 용어 + where: string; // 적용 위치 안내 (설정 화면 표시용) +}; + +// 용어 카탈로그. base 가 fallback 이므로 배선된 화면은 설정이 비어 있어도 기존과 동일하게 보인다. +export const LABEL_CATALOG: LabelCatalogEntry[] = [ + { key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' }, + { key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' }, + { key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' }, + { key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' }, + { key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' }, + { key: 'delivery_type.2', base: '지정택배배송', where: '배송유형 선택지 2' }, + { key: 'delivery_type.3', base: '픽업배송', where: '배송유형 선택지 3' }, +]; + +export const LABEL_DEFAULTS: Record = Object.fromEntries( + LABEL_CATALOG.map((e) => [e.key, e.base]), +); + +export const CUSTOM_FIELD_TYPE_LABEL: Record = { + text: '텍스트', + number: '숫자', + boolean: '예/아니오', +}; diff --git a/negodata/front/src/features/settings/useCompanySettings.ts b/negodata/front/src/features/settings/useCompanySettings.ts new file mode 100644 index 0000000..d9652e7 --- /dev/null +++ b/negodata/front/src/features/settings/useCompanySettings.ts @@ -0,0 +1,36 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useGetSettings, updateSettings, getGetSettingsQueryKey } from '@/api/generated/company-settings/company-settings'; +import { LABEL_DEFAULTS, type CompanySettings } from './catalog'; + +// 회사 커스터마이징 설정 조회 + 저장. +// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다. +export function useCompanySettings() { + const queryClient = useQueryClient(); + const query = useGetSettings({ query: { staleTime: 5 * 60 * 1000 } }); + const settings: CompanySettings = (query.data?.settings as CompanySettings) ?? {}; + + const save = async (next: CompanySettings) => { + const res = await updateSettings({ settings: next as Record }); + if (res.result?.success === false) throw new Error(res.result.desc || '설정 저장에 실패했습니다.'); + await queryClient.invalidateQueries({ queryKey: getGetSettingsQueryKey() }); + }; + + return { settings, isLoading: query.isLoading, save }; +} + +// 용어 라벨 헬퍼. label('item.price') → 회사 설정 용어, 없으면 카탈로그 기본값. +export function useLabels() { + const { settings } = useCompanySettings(); + const overrides = settings.labels ?? {}; + return (key: string): string => overrides[key] || LABEL_DEFAULTS[key] || key; +} + +// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData). +export function useBranding() { + const { settings } = useCompanySettings(); + return { + serviceName: settings.branding?.service_name || 'NegoData', + logoUrl: settings.branding?.logo_url || null, + primaryColor: settings.branding?.primary_color || null, + }; +} diff --git a/negodata/front/src/features/statistics/StatisticsView.tsx b/negodata/front/src/features/statistics/StatisticsView.tsx index 3c26cf0..349f80a 100644 --- a/negodata/front/src/features/statistics/StatisticsView.tsx +++ b/negodata/front/src/features/statistics/StatisticsView.tsx @@ -2,6 +2,7 @@ import { Award, Percent, RefreshCw, Target, TrendingDown, CircleCheckBig } from import { Panel } from './components/Panel'; import { StatTile } from './components/StatTile'; import { SavingsTrendChart } from './components/SavingsTrendChart'; +import { MarkupTrendChart } from './components/MarkupTrendChart'; import { OutcomeChart } from './components/OutcomeChart'; import { ParticipationChart } from './components/ParticipationChart'; import { TypeSplitChart } from './components/TypeSplitChart'; @@ -9,9 +10,11 @@ import { CategoryChart } from './components/CategoryChart'; import { CardEffectChart } from './components/CardEffectChart'; import { wonCompact, pct, signedWonCompact } from './fmt'; import type { StatData } from './types'; +import { useLabels } from '@/features/settings/useCompanySettings'; // 통계 본문. KPI 요약 + 절감 분석 + 성사/프로세스 + 카드 효과. scope(회사/내견적)별로 동일 레이아웃. export function StatisticsView({ data }: { data: StatData }) { + const label = useLabels(); // 회사 설정 용어(카테고리 등) const k = data.kpi; return (
@@ -40,6 +43,10 @@ export function StatisticsView({ data }: { data: StatData }) { > + + + + @@ -57,7 +64,7 @@ export function StatisticsView({ data }: { data: StatData }) { {/* 카테고리 · 카드 */}
- + diff --git a/negodata/front/src/features/statistics/api.ts b/negodata/front/src/features/statistics/api.ts index dc6458e..a5825e4 100644 --- a/negodata/front/src/features/statistics/api.ts +++ b/negodata/front/src/features/statistics/api.ts @@ -17,8 +17,10 @@ function mapScope(s?: ApiScope): StatData { savingsDeltaMoM: k.savings_delta_mom ?? 0, closedCount: k.closed_count ?? 0, regenAvgRound: k.regen_avg_round ?? 0, + markupSuppressionRate: k.markup_suppression_rate ?? 0, }, trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })), + markupTrend: (s?.markup_trend ?? []).map((t) => ({ month: t.month, rate: t.rate ?? 0 })), outcome: { awarded: o.awarded ?? 0, openPrice: o.open_price ?? 0, diff --git a/negodata/front/src/features/statistics/components/MarkupTrendChart.tsx b/negodata/front/src/features/statistics/components/MarkupTrendChart.tsx new file mode 100644 index 0000000..18ece5b --- /dev/null +++ b/negodata/front/src/features/statistics/components/MarkupTrendChart.tsx @@ -0,0 +1,36 @@ +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart'; +import { Typography } from '@/components/ui/typography'; +import { themeOf } from '../palette'; +import { pct, monthLabel } from '../fmt'; +import type { MarkupPoint } from '../types'; + +// 월별 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율). +const config = { + rate: { label: '인상억제율', theme: themeOf('rose') }, +} satisfies ChartConfig; + +export function MarkupTrendChart({ data }: { data: MarkupPoint[] }) { + return ( + + + + + pct(Number(v))} /> + } /> + + + + ); +} + +function MarkupTooltip({ active, payload }: { active?: boolean; payload?: { payload: MarkupPoint }[] }) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+ {monthLabel(p.month)} + 인상억제율 {pct(p.rate)} +
+ ); +} diff --git a/negodata/front/src/features/statistics/types.ts b/negodata/front/src/features/statistics/types.ts index 3a631c8..3909913 100644 --- a/negodata/front/src/features/statistics/types.ts +++ b/negodata/front/src/features/statistics/types.ts @@ -11,6 +11,7 @@ export interface StatKpi { savingsDeltaMoM: number; // 전월 대비 절감액 증감 closedCount: number; // 마감 견적 수(창) regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음) + markupSuppressionRate: number; // 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율) } export interface MonthPoint { @@ -19,6 +20,11 @@ export interface MonthPoint { rate: number; } +export interface MarkupPoint { + month: string; // 'YYYY-MM' + rate: number; // 인상억제율 +} + export interface OutcomeBreakdown { awarded: number; openPrice: number; // 가격 미달 @@ -57,6 +63,7 @@ export interface CardTypeUsage { export interface StatData { kpi: StatKpi; trend: MonthPoint[]; + markupTrend: MarkupPoint[]; outcome: OutcomeBreakdown; participation: ParticipationBreakdown; typeSplit: TypeSplitRow[]; diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index a2b2b1b..3334358 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -1,10 +1,13 @@ -import { Plus, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { Plus, Upload, Download, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react'; import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { PageContainer } from '@/components/layout/PageContainer'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useAuthStore } from '@/stores/auth'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useServerList } from '@/lib/useServerList'; import { usePartners } from '@/features/partners/hooks/usePartners'; @@ -36,6 +39,27 @@ export default function PartnersPage() { const formMode: 'create' | 'edit' = editId ? 'edit' : 'create'; const isFormOpen = overlay.has('new') || !!editing; + // 협력사 삭제는 최고관리자 전용(단건 삭제와 동일 규칙) — 일괄삭제 버튼도 최고관리자에게만 노출. + const isSuperAdmin = useAuthStore((st) => st.user?.role === '최고관리자'); + const [selectedIds, setSelectedIds] = useState([]); + + const handleBulkDelete = async () => { + if (selectedIds.length === 0) return; + if (!(await confirm({ title: '선택 협력사 일괄 삭제', description: `선택한 ${selectedIds.length}개 협력사를 명부에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true }))) return; + let ok = 0; + let fail = 0; + for (const id of selectedIds) { + try { + await deletePartner(id); + ok += 1; + } catch { + fail += 1; + } + } + setSelectedIds([]); + showToast(fail === 0 ? `${ok}개 협력사가 삭제되었습니다.` : `${ok}개 삭제 · ${fail}개 실패`, fail === 0 ? 'info' : 'error'); + }; + const openCreate = () => overlay.open('new'); const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id); @@ -55,6 +79,19 @@ export default function PartnersPage() { + {isSuperAdmin && ( + + )} + }> @@ -92,6 +129,8 @@ export default function PartnersPage() { overlay.open('new'); const openEdit = (prod: Product) => overlay.open('detail', prod.item_id); + // 선택 일괄삭제(IMK #8) — 단건 삭제 API 루프(엑셀 일괄등록과 동일 패턴). 소유자 아닌 행은 서버가 거부 → 실패 집계. + const handleBulkDelete = async () => { + if (selectedIds.length === 0) return; + if (!(await confirm({ title: '선택 상품 일괄 삭제', description: `선택한 ${selectedIds.length}개 상품을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true }))) return; + let ok = 0; + let fail = 0; + for (const id of selectedIds) { + try { + await deleteProduct(id); + ok += 1; + } catch { + fail += 1; + } + } + setSelectedIds([]); + showToast(fail === 0 ? `${ok}개 상품이 삭제되었습니다.` : `${ok}개 삭제 · ${fail}개 실패(권한 등)`, fail === 0 ? 'info' : 'error'); + }; + const handleDeleteProduct = async (id: string, prodName: string) => { if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) { try { @@ -73,6 +94,17 @@ export default function ProductsPage() { + +