[feat] 공급사 포털·backend: 협상완료 부가정보 입력(#5)·포털 브랜딩/로고·구분삭제(#19)·카드메타 nego 저장

This commit is contained in:
Mina Choi 2026-07-22 15:20:24 +09:00
parent 88edcfed63
commit 5d0fbbcf6a
30 changed files with 529 additions and 52 deletions

View File

@ -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,

View File

@ -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

View File

@ -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 = (

View File

@ -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):

View File

@ -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). 재진입 시 폼 프리필용")
# 대화 히스토리(재진입 복원)

View File

@ -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")

View File

@ -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))

View File

@ -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:

View File

@ -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)]
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).

View File

@ -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) 을 반환한다.

View File

@ -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 ?? [],
}
}

View File

@ -46,6 +46,7 @@ export interface ChatInitResponse {
item_min_order_quantity?: string
item_vat_yn?: boolean
item_delivery_fee_yn?: boolean
custom?: Record<string, unknown>
}
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 ?? '',

View File

@ -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'

View File

@ -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<ExtraInfoResponse> => {
const res = await http.post<ExtraInfoResponse>(
`/v1/negotiation/sessions/${sessionId}/extra-info`,
body,
)
return res.data
},
}

View File

@ -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() })
},
})
}

View File

@ -55,6 +55,7 @@ export interface SessionListItem {
item_name: string
model_name: string
maker_name: string
custom: Record<string, unknown> // 협상완료 부가정보(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<string, unknown>
}
export interface ExtraInfoResponse {
result: ApiResult
session_id: string
}
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
export interface ParticipateResponse {
result: ApiResult

View File

@ -22,6 +22,9 @@ export interface LogoProps extends Omit<ComponentProps<'div'>, '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 (
<div className={cn('flex items-center', s.gap, className)} {...props}>
<img
src={sources[variant]}
alt={withText ? '' : alt}
src={logoUrl || sources[variant]}
alt={withText ? '' : (serviceName || alt)}
className={cn('w-auto select-none', s.img)}
/>
{withText && (
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
iMarket Korea
{name}
</span>
)}
</div>

View File

@ -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 && (
<Indicator number={message.indicator_value} />
)}
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}
{message.bot_chat_type === 'summaryRSP' && message.summary && (
<>
<Summary data={message.summary} />
<ExtraInfoForm />
</>
)}
{message.bot_chat_type === 'summaryCM' && message.summary && (
<BidSummary
itemName={message.summary.item_name}

View File

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
import { useMeQuery, useSaveExtraInfoMutation, getApiErrorMessage } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 타결(Summary) 직후 부가정보 입력 폼. 필드 정의(session_fields)는 회사 설정(/me)에서 온다.
// 저장하면 sessions.custom 에 기록되고 negodata 견적상세에 표시된다. 정의가 없으면 렌더하지 않는다.
export function ExtraInfoForm() {
const sessionId = useChatStore((s) => s.sessionId)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const save = useSaveExtraInfoMutation()
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
const [values, setValues] = useState<Record<string, unknown>>({})
const [inited, setInited] = useState(false)
const [saved, setSaved] = useState(false)
// fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존.
useEffect(() => {
if (inited || fields.length === 0) return
const init: Record<string, unknown> = {}
for (const f of fields) init[f.key] = existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
setValues(init)
setInited(true)
}, [inited, fields, existing])
if (fields.length === 0) return null
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
const handleSave = () => {
const custom: Record<string, unknown> = {}
for (const f of fields) {
const v = values[f.key]
if (f.type === 'boolean') custom[f.key] = !!v
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
}
save.mutate(
{ sessionId, request: { custom } },
{
onSuccess: () => {
setSaved(true)
toast.success('부가정보가 저장되었습니다.')
},
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return (
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<CheckCircle2 className="size-5 text-brand-600" />
<h3 className="text-sm font-bold text-neutral-90">부가정보 입력</h3>
</div>
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
협상이 완료되었습니다. 아래 정보를 입력해 주세요. (저장 후에도 세션 목록에서 수정할 수 있습니다.)
</p>
<div className="mt-4 space-y-4">
{fields.map((f) => (
<div key={f.key} className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
{f.type === 'boolean' ? (
<button
type="button"
role="switch"
aria-checked={!!values[f.key]}
onClick={() => set(f.key, !values[f.key])}
disabled={saved}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`}
>
<span
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
disabled={saved}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:bg-neutral-10"
placeholder={f.label}
/>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleSave}
disabled={save.isPending || saved}
className="mt-4 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{saved ? '저장 완료' : save.isPending ? '저장 중…' : '부가정보 저장'}
</button>
</div>
)
}

View File

@ -29,6 +29,7 @@ const initialState: ChatInitData = {
item_spec: '',
quotation_memo: '',
quotation_end_time: '',
custom: {},
}
export const useChatInitStore = create<ChatInitStore>((set) => ({

View File

@ -81,4 +81,5 @@ export type ChatInitData = {
item_spec: string
quotation_memo: string
quotation_end_time: string
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
}

View File

@ -0,0 +1,115 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { useMeQuery } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import type { ListItem } from '../types'
export interface ExtraInfoPopupProps {
target: ListItem
onClose: () => void
/** 부가정보 저장 (custom = {key: value}) */
onSubmit: (custom: Record<string, unknown>) => void
}
// 협상완료 부가정보 입력 팝업. 필드 정의(session_fields)는 회사 설정(/me)에서 오고, 기존 입력값(target.custom)으로 프리필.
export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProps) {
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const [values, setValues] = useState<Record<string, unknown>>(() => {
const init: Record<string, unknown> = {}
for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '')
return init
})
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
const handleSubmit = () => {
// 빈 값은 제외하고 저장(부분 입력 허용)
const out: Record<string, unknown> = {}
for (const f of fields) {
const v = values[f.key]
if (f.type === 'boolean') out[f.key] = !!v
else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v
}
onSubmit(out)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90">협상완료 부가정보</h3>
<p className="mt-0.5 text-xs text-neutral-60">{target.qt_number} · {target.item_name}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-4 p-5">
{fields.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-60">입력할 부가정보 항목이 없습니다.</p>
) : (
fields.map((f) => (
<div key={f.key} className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
{f.type === 'boolean' ? (
<button
type="button"
role="switch"
aria-checked={!!values[f.key]}
onClick={() => set(f.key, !values[f.key])}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`}
>
<span
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
placeholder={f.label}
/>
)}
</div>
))
)}
</div>
<div className="flex gap-2 border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
>
취소
</button>
<button
type="button"
onClick={handleSubmit}
disabled={fields.length === 0}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white hover:bg-brand-700 disabled:opacity-40"
>
저장
</button>
</div>
</div>
</Modal>
)
}

View File

@ -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 (
<div className="flex items-center justify-center py-16">
@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }:
return (
<div className="divide-y divide-border">
{items.map((item) => (
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} />
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
))}
</div>
)
@ -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 (
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="rounded-md bg-neutral-20 px-1.5 py-0.5 text-[11px] font-semibold text-neutral-70">
{item.qt_type || '-'}
</span>
<span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span>
</div>
<p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p>
@ -76,8 +78,17 @@ function Card({
<span>마감 {formatKstDateTime(item.qt_end_time)}</span>
</div>
{(canEnter || canReject) && (
{(canEnter || canReject || isDone) && (
<div className="mt-3 flex gap-2">
{isDone && (
<button
type="button"
onClick={() => onExtraInfo(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
</button>
)}
{canReject && (
<button
type="button"

View File

@ -10,19 +10,19 @@ interface WorkspaceTableProps {
busyId?: string | null
onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void
}
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }: WorkspaceTableProps) {
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceTableProps) {
return (
<div className="w-full overflow-x-auto">
<table className="w-full min-w-[900px] border-collapse">
<thead>
<tr className="border-b border-border bg-table-header">
<th className={HEAD}>견적번호</th>
<th className={HEAD}>구분</th>
<th className={HEAD}>상품</th>
<th className={HEAD}>제조사</th>
<th className={cn(HEAD, 'text-center')}>상태</th>
@ -41,7 +41,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }:
</StateRow>
) : (
items.map((item) => (
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} />
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
))
)}
</tbody>
@ -55,27 +55,26 @@ function Row({
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 (
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
<td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}>
{item.qt_number || '-'}
</td>
<td className={cn(CELL, 'whitespace-nowrap')}>
<span className="rounded-md bg-neutral-20 px-2 py-0.5 text-xs font-semibold text-neutral-70">
{item.qt_type || '-'}
</span>
</td>
<td className={CELL}>
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
<p className="mt-0.5 text-xs text-neutral-60">
@ -93,6 +92,15 @@ function Row({
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td>
<td className={CELL}>
<div className="flex items-center justify-end gap-2">
{isDone && (
<button
type="button"
onClick={() => onExtraInfo(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
</button>
)}
{canReject && (
<button
type="button"
@ -124,7 +132,7 @@ function Row({
function StateRow({ children }: { children: ReactNode }) {
return (
<tr>
<td colSpan={7} className="py-20">
<td colSpan={6} className="py-20">
<div className="flex items-center justify-center">{children}</div>
</td>
</tr>

View File

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router'
import { useShallow } from 'zustand/react/shallow'
import { Search } from 'lucide-react'
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation } from '@/apis'
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from '@/apis'
import { cn, toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList'
import { useListStore } from '@/features/list/stores/useListStore'
@ -12,6 +12,7 @@ import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
import { Pagination } from '@/features/list/components/Pagination'
import { RejectPopup } from '@/features/list/components/RejectPopup'
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
import type { ListItem } from '@/features/list/types'
// 상태별 거부 불가 안내
@ -21,8 +22,6 @@ const REJECT_BLOCKED: Record<string, string> = {
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
}
const TYPE_OPTIONS = ['재견적', '재협상', '신규견적', '신규협상']
export function ListWorkspace() {
const navigate = useNavigate()
const { data: user } = useMeQuery()
@ -30,19 +29,19 @@ export function ListWorkspace() {
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
const participate = useParticipateMutation()
const reject = useRejectMutation()
const saveExtra = useSaveExtraInfoMutation()
const { selectedStatus, setStatus, selectedType, setType } = useListStore(
const { selectedStatus, setStatus } = useListStore(
useShallow((s) => ({
selectedStatus: s.selectedStatus,
setStatus: s.setStatus,
selectedType: s.selectedType,
setType: s.setType,
})),
)
const [search, setSearch] = useState('')
const [enteringId, setEnteringId] = useState<string | null>(null)
const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null)
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
const handleEnter = (item: ListItem) => {
setEnteringId(item.session_id)
@ -75,6 +74,17 @@ export function ListWorkspace() {
)
}
const handleExtraSubmit = (custom: Record<string, unknown>) => {
if (!extraTarget) return
saveExtra.mutate(
{ sessionId: extraTarget.session_id, request: { custom } },
{
onSuccess: () => toast.success('부가정보가 저장되었습니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return (
<div className="space-y-5">
{/* 환영 배너 */}
@ -97,18 +107,6 @@ export function ListWorkspace() {
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
<StatusTabs selected={selectedStatus} onSelect={setStatus} />
<div className="flex w-full items-center gap-2 lg:w-auto">
<select
value={selectedType ?? ''}
onChange={(e) => setType(e.target.value || null)}
className="h-9 shrink-0 rounded-xl border border-border bg-white px-3 text-xs font-semibold text-neutral-80 outline-none transition-all focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
>
<option value="">구분 전체</option>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<div className="relative flex-1 lg:flex-none">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
<input
@ -132,6 +130,7 @@ export function ListWorkspace() {
busyId={participate.isPending ? enteringId : null}
onEnter={handleEnter}
onReject={handleReject}
onExtraInfo={setExtraTarget}
/>
</div>
<div className="lg:hidden">
@ -141,6 +140,7 @@ export function ListWorkspace() {
busyId={participate.isPending ? enteringId : null}
onEnter={handleEnter}
onReject={handleReject}
onExtraInfo={setExtraTarget}
/>
</div>
@ -152,6 +152,10 @@ export function ListWorkspace() {
{rejectTarget && (
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
)}
{extraTarget && (
<ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} />
)}
</div>
)
}

View File

@ -1,17 +1,16 @@
import { useMemo } from 'react'
import { useSessionListQuery } from '@/apis'
import { useListStore } from '@/features/list/stores/useListStore'
import { deadlineToOrder, statusLabelToCode, toListItem, typeLabelToCode } from '@/features/list/lib/adapter'
import { deadlineToOrder, statusLabelToCode, toListItem } from '@/features/list/lib/adapter'
const PAGE_SIZE = 20
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
export function useList() {
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
const { selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
const query = useSessionListQuery({
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
page: currentPage,
page_size: PAGE_SIZE,

View File

@ -37,5 +37,6 @@ export function toListItem(api: SessionListItem): ListItem {
item_name: api.item_name,
model_name: api.model_name,
maker_name: api.maker_name,
custom: api.custom ?? {},
}
}

View File

@ -8,4 +8,5 @@ export type ListItem = {
item_name: string
model_name: string
maker_name: string
custom: Record<string, unknown> // 협상완료 부가정보(입력값). 미입력이면 {}
}

View File

@ -1,5 +1,6 @@
import { type ReactNode } from 'react'
import { Logo } from '@/components'
import { useMeQuery } from '@/apis'
import { cn } from '@/lib'
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
@ -43,11 +44,12 @@ export function MainLayout({
header,
children,
}: MainLayoutProps) {
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
const panes = (
<>
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
<div className={styles.logoHeader}>
<Logo size="sm" />
<Logo size="sm" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
{logoAction}
</div>
{sidebar}

View File

@ -15,7 +15,7 @@ export function PortalHeader() {
return (
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6">
<Logo size="md" />
<Logo size="md" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
<div className="flex items-center gap-2 sm:gap-3">
<button