[feat] negosium/협상: 협상 종료폼 통일·의견 수취 — rejectRSP/CM를 단일 RejectForm(사유·희망가·의견)으로 통합해 액션바 렌더, 타결 부가정보에 의견 추가(custom.opinion), 결렬 제출값 파싱해 reject_reason/reject_price 컬럼 저장, 상품상세 필드 라벨 회사설정 연동·협상 단가 VAT별도 표기
This commit is contained in:
parent
b33ae05cd6
commit
a56589c6d9
@ -78,6 +78,7 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
|||||||
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
||||||
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
||||||
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
||||||
|
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")
|
||||||
|
|
||||||
|
|
||||||
# 대화 히스토리(재진입 복원)
|
# 대화 히스토리(재진입 복원)
|
||||||
|
|||||||
@ -24,6 +24,7 @@ from common.logger import LOG
|
|||||||
from common.models.gmodel import UserInfo
|
from common.models.gmodel import UserInfo
|
||||||
from crud.chat_crud import ChatCRUD, IChatCRUD
|
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||||
|
from crud.user_crud import IUserCRUD, UserCRUD
|
||||||
from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||||
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
@ -44,11 +45,13 @@ class ChatService:
|
|||||||
auth: AuthService = Depends(AuthService),
|
auth: AuthService = Depends(AuthService),
|
||||||
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
||||||
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
||||||
|
user_crud: IUserCRUD = Depends(UserCRUD),
|
||||||
agent: IAgentClient = Depends(get_agent_client),
|
agent: IAgentClient = Depends(get_agent_client),
|
||||||
):
|
):
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
self.session_crud = session_crud
|
self.session_crud = session_crud
|
||||||
self.chat_crud = chat_crud
|
self.chat_crud = chat_crud
|
||||||
|
self.user_crud = user_crud
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
|
|
||||||
# ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ----
|
# ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ----
|
||||||
@ -59,6 +62,27 @@ class ChatService:
|
|||||||
digits = "".join(ch for ch in text if ch.isdigit())
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
return int(digits) if digits else None
|
return int(digits) if digits else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_reject(text: Optional[str]) -> dict:
|
||||||
|
"""통일 결렬 폼 제출 문자열 파싱 → {offer_price, reason, opinion}.
|
||||||
|
형식: '공급희망가격-{원}, 합의불가사유-{사유}, 의견-{의견}' (사유는 '기타-{내용}' 가능).
|
||||||
|
의견은 자유서술이라 콤마 포함 가능 → 맨 뒤 '의견-' 기준으로 먼저 떼어낸다."""
|
||||||
|
s = text or ""
|
||||||
|
opinion = None
|
||||||
|
if ", 의견-" in s:
|
||||||
|
s, opinion = s.split(", 의견-", 1)
|
||||||
|
reason = None
|
||||||
|
if ", 합의불가사유-" in s:
|
||||||
|
price_part, reason = s.split(", 합의불가사유-", 1)
|
||||||
|
else:
|
||||||
|
price_part = s
|
||||||
|
price_digits = "".join(ch for ch in price_part.replace("공급희망가격-", "") if ch.isdigit())
|
||||||
|
return {
|
||||||
|
"offer_price": int(price_digits) if price_digits else None,
|
||||||
|
"reason": reason or None,
|
||||||
|
"opinion": (opinion.strip() or None) if opinion is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||||
if not target_price:
|
if not target_price:
|
||||||
@ -229,6 +253,13 @@ class ChatService:
|
|||||||
res.item_vat_yn = item.vat_yn
|
res.item_vat_yn = item.vat_yn
|
||||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||||
res.custom = sess.custom or {}
|
res.custom = sess.custom or {}
|
||||||
|
|
||||||
|
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
|
||||||
|
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
|
||||||
|
)
|
||||||
|
res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def _ensure_in_progress(self, sess, quote) -> None:
|
async def _ensure_in_progress(self, sess, quote) -> None:
|
||||||
@ -455,9 +486,14 @@ class ChatService:
|
|||||||
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
|
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
|
||||||
else:
|
else:
|
||||||
new_status = SessionStatus.REJECTED.value
|
new_status = SessionStatus.REJECTED.value
|
||||||
|
parsed = self._parse_reject(user_input)
|
||||||
funcs.append(lambda s: self.chat_crud.finalize_session(
|
funcs.append(lambda s: self.chat_crud.finalize_session(
|
||||||
s, sess.session_id, new_status,
|
s, sess.session_id, new_status,
|
||||||
reject_reason=(user_input or None), reject_price=price,
|
reject_reason=parsed["reason"], reject_price=parsed["offer_price"],
|
||||||
|
))
|
||||||
|
if parsed["opinion"]:
|
||||||
|
funcs.append(lambda s, op=parsed["opinion"]: self.session_crud.merge_session_custom(
|
||||||
|
s, sess.session_id, sess.supplier_id, {"opinion": op},
|
||||||
))
|
))
|
||||||
|
|
||||||
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
|
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export interface ChatInitResponse {
|
|||||||
item_vat_yn?: boolean
|
item_vat_yn?: boolean
|
||||||
item_delivery_fee_yn?: boolean
|
item_delivery_fee_yn?: boolean
|
||||||
custom?: Record<string, unknown>
|
custom?: Record<string, unknown>
|
||||||
|
labels?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessagesResponse {
|
export interface ChatMessagesResponse {
|
||||||
@ -104,5 +105,6 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
|
|||||||
item_spec: r.item_spec ?? '',
|
item_spec: r.item_spec ?? '',
|
||||||
quotation_memo: r.quotation_memo ?? '',
|
quotation_memo: r.quotation_memo ?? '',
|
||||||
quotation_end_time: r.quotation_end_time ?? '',
|
quotation_end_time: r.quotation_end_time ?? '',
|
||||||
|
labels: r.labels ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
|||||||
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
||||||
import { Summary } from '@/features/chat/components/templates/Summary'
|
import { Summary } from '@/features/chat/components/templates/Summary'
|
||||||
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
||||||
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
|
|
||||||
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
|
||||||
|
|
||||||
const AI_LABEL = '아이마켓코리아 (구매 MD)'
|
const AI_LABEL = '아이마켓코리아 (구매 MD)'
|
||||||
|
|
||||||
@ -16,12 +14,9 @@ export function ChatMessage() {
|
|||||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 w-full min-h-0">
|
<div className="flex-1 w-full min-h-0">
|
||||||
{/* lg:pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추는 값 — 그 카드가 없는 lg 미만에선
|
|
||||||
스텝바 바로 아래 여백으로만 남아 첫 메시지가 위로 안 붙으므로 뺀다.
|
|
||||||
스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="chat-scroll h-full overflow-y-auto flex flex-col pt-0 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
|
className="chat-scroll h-full overflow-y-auto flex flex-col pt-4 pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
|
||||||
>
|
>
|
||||||
<ChatList scrollRef={scrollRef} />
|
<ChatList scrollRef={scrollRef} />
|
||||||
</div>
|
</div>
|
||||||
@ -133,8 +128,6 @@ const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageT
|
|||||||
isVAT={message.summary.item_isVAT}
|
isVAT={message.summary.item_isVAT}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{message.bot_chat_type === 'rejectRSP' && <RejectRSP />}
|
|
||||||
{message.bot_chat_type === 'rejectCM' && <RejectCM />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,6 +15,9 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
|
|||||||
const fields: SessionField[] = user?.sessionFields ?? []
|
const fields: SessionField[] = user?.sessionFields ?? []
|
||||||
const save = useSaveExtraInfoMutation()
|
const save = useSaveExtraInfoMutation()
|
||||||
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
|
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
|
||||||
|
// 의견은 session_fields 와 무관한 공통 필드 — 타결·결렬 모두 항상 받는다. custom.opinion 에 저장.
|
||||||
|
const [opinion, setOpinion] = useState<string | null>(null)
|
||||||
|
const opinionValue = opinion ?? String(existing?.opinion ?? '')
|
||||||
|
|
||||||
// 사용자가 건드린 값만 state 로 두고, 나머지는 기존값/기본값에서 렌더마다 파생한다(초기화 effect 불필요).
|
// 사용자가 건드린 값만 state 로 두고, 나머지는 기존값/기본값에서 렌더마다 파생한다(초기화 effect 불필요).
|
||||||
const [overrides, setOverrides] = useState<Record<string, unknown>>({})
|
const [overrides, setOverrides] = useState<Record<string, unknown>>({})
|
||||||
@ -23,17 +26,6 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
|
|||||||
|
|
||||||
const proceed = () => sendMessage(proceedText)
|
const proceed = () => sendMessage(proceedText)
|
||||||
|
|
||||||
// 필드 미정의 회사 → 부가정보 없이 동의만.
|
|
||||||
if (fields.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full justify-center">
|
|
||||||
<button className={PRIMARY} onClick={proceed}>
|
|
||||||
{proceedText || '확인'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const set = (key: string, value: unknown) => setOverrides((v) => ({ ...v, [key]: value }))
|
const set = (key: string, value: unknown) => setOverrides((v) => ({ ...v, [key]: value }))
|
||||||
|
|
||||||
// 저장 성공 후에만 동의(협상 종료)로 넘어간다 — 저장 실패 시 화면 유지.
|
// 저장 성공 후에만 동의(협상 종료)로 넘어간다 — 저장 실패 시 화면 유지.
|
||||||
@ -45,6 +37,8 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
|
|||||||
if (f.type === 'boolean') custom[f.key] = !!v
|
if (f.type === 'boolean') custom[f.key] = !!v
|
||||||
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
|
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
|
||||||
}
|
}
|
||||||
|
if (opinionValue.trim()) custom.opinion = opinionValue.trim()
|
||||||
|
if (Object.keys(custom).length === 0) { proceed(); return } // 입력 없음 → 저장 생략하고 종료
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{ sessionId, request: { custom } },
|
{ sessionId, request: { custom } },
|
||||||
{
|
{
|
||||||
@ -102,6 +96,20 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{/* 의견 — 타결·결렬 공통 필드(IMK #18). custom.opinion 저장. */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-sm font-semibold text-neutral-80">
|
||||||
|
의견 <span className="font-normal text-neutral-50">(선택)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={opinionValue}
|
||||||
|
onChange={(e) => setOpinion(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
maxLength={255}
|
||||||
|
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
|
||||||
|
className="w-full resize-none rounded-xl border border-border bg-white px-3 py-2 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={handleSaveAndProceed} disabled={save.isPending} className={`${PRIMARY} mt-3 w-full`}>
|
<button type="button" onClick={handleSaveAndProceed} disabled={save.isPending} className={`${PRIMARY} mt-3 w-full`}>
|
||||||
{save.isPending ? '저장 중…' : proceedText || '저장하고 마무리'}
|
{save.isPending ? '저장 중…' : proceedText || '저장하고 마무리'}
|
||||||
|
|||||||
@ -38,14 +38,16 @@ function ItemInfo() {
|
|||||||
const {
|
const {
|
||||||
item_code,
|
item_code,
|
||||||
item_price,
|
item_price,
|
||||||
item_vat_yn,
|
|
||||||
item_model_name,
|
item_model_name,
|
||||||
item_maker_name,
|
item_maker_name,
|
||||||
item_min_order_quantity,
|
item_min_order_quantity,
|
||||||
item_lead_time,
|
item_lead_time,
|
||||||
item_spec,
|
item_spec,
|
||||||
item_name,
|
item_name,
|
||||||
|
labels,
|
||||||
} = useChatInitStore()
|
} = useChatInitStore()
|
||||||
|
// 회사 설정 라벨(companies.settings.labels)로 필드명 치환. 미설정 시 기본 라벨.
|
||||||
|
const fieldLabel = (key: string, fallback: string) => labels?.[key] || fallback
|
||||||
|
|
||||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
@ -71,7 +73,7 @@ function ItemInfo() {
|
|||||||
const formatPrice = (price: number) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
const formatPrice = (price: number) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
const isNewItem = !item_code && !item_price
|
const isNewItem = !item_code && !item_price
|
||||||
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}원`
|
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}원`
|
||||||
const formattedPrice = `${priceText}(${item_vat_yn || 'VAT별도'})`
|
const formattedPrice = `${priceText}(VAT별도)` // 협상 화면 상품 단가는 VAT별도 표기(IMK #11)
|
||||||
|
|
||||||
const renderRow = (title: string, data: string) => {
|
const renderRow = (title: string, data: string) => {
|
||||||
const displayData = data || '-'
|
const displayData = data || '-'
|
||||||
@ -105,15 +107,15 @@ function ItemInfo() {
|
|||||||
|
|
||||||
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
|
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
|
||||||
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
|
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
|
||||||
{renderRow('상품코드', item_code)}
|
{renderRow(fieldLabel('item.code', '상품코드'), item_code)}
|
||||||
{renderRow('단가', formattedPrice)}
|
{renderRow(fieldLabel('item.price', '단가'), formattedPrice)}
|
||||||
{renderRow('모델명', item_model_name)}
|
{renderRow(fieldLabel('item.model_name', '모델명'), item_model_name)}
|
||||||
{renderRow('제조사', item_maker_name)}
|
{renderRow(fieldLabel('item.manufacturer', '제조사'), item_maker_name)}
|
||||||
{renderRow('최소주문수량', item_min_order_quantity)}
|
{renderRow(fieldLabel('item.moq', '최소주문수량'), item_min_order_quantity)}
|
||||||
{renderRow('리드타임', formatLeadTime(item_lead_time))}
|
{renderRow(fieldLabel('lead_time', '리드타임'), formatLeadTime(item_lead_time))}
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-3 self-stretch py-2">
|
<div className="flex items-start justify-between gap-3 self-stretch py-2">
|
||||||
<span className="shrink-0 text-sm text-neutral-60">규격</span>
|
<span className="shrink-0 text-sm text-neutral-60">{fieldLabel('item.spec', '규격')}</span>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 flex-1 overflow-hidden whitespace-normal break-words break-keep text-right text-[13px] font-semibold text-neutral-90 cursor-default"
|
className="min-w-0 flex-1 overflow-hidden whitespace-normal break-words break-keep text-right text-[13px] font-semibold text-neutral-90 cursor-default"
|
||||||
onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)}
|
onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)}
|
||||||
|
|||||||
187
frontend/src/features/chat/components/RejectForm.tsx
Normal file
187
frontend/src/features/chat/components/RejectForm.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib'
|
||||||
|
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||||
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
||||||
|
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
|
||||||
|
import { findRestoreScript, restoreRejectRSP, extractPart } from '@/features/chat/lib/rejectForm'
|
||||||
|
|
||||||
|
const MAX_PRICE = 999999999999999
|
||||||
|
|
||||||
|
// 합의 불가 사유 프리셋 (rejectRSP 와 동일 목록)
|
||||||
|
const REASONS = [
|
||||||
|
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
|
||||||
|
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
|
||||||
|
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
|
||||||
|
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
|
||||||
|
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
|
||||||
|
export function RejectForm() {
|
||||||
|
const isLoading = useChatStore((s) => s.isLoading)
|
||||||
|
const sendMessage = useChatStore((s) => s.sendMessage)
|
||||||
|
const messages = useChatStore((s) => s.messages)
|
||||||
|
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
|
||||||
|
const isVAT = item_vat_yn === 'VAT별도'
|
||||||
|
|
||||||
|
// 직전 제출 내역에서 1회 복원 (rejectRSP/rejectCM 어느 유형이든).
|
||||||
|
const priorScript = useState(
|
||||||
|
() => findRestoreScript(messages, 'rejectRSP') ?? findRestoreScript(messages, 'rejectCM'),
|
||||||
|
)[0]
|
||||||
|
const restored = useState(() => restoreRejectRSP(priorScript))[0]
|
||||||
|
const [price, setPrice] = useState(restored.price)
|
||||||
|
const [selectedReason, setSelectedReason] = useState(restored.selectedReason)
|
||||||
|
const [reason, setReason] = useState(restored.reason)
|
||||||
|
const [opinion, setOpinion] = useState(() => extractPart(priorScript, '의견-'))
|
||||||
|
const [otherErrorMessage, setOtherErrorMessage] = useState('')
|
||||||
|
const [priceErrorMessage, setPriceErrorMessage] = useState('')
|
||||||
|
const [radioErrorMessage, setRadioErrorMessage] = useState('')
|
||||||
|
|
||||||
|
const isSubmitted = useMemo(
|
||||||
|
() => Boolean(findRestoreScript(messages, 'rejectRSP') ?? findRestoreScript(messages, 'rejectCM')),
|
||||||
|
[messages],
|
||||||
|
)
|
||||||
|
// 사유는 필수(20번), 의견은 선택(18번).
|
||||||
|
const isValid =
|
||||||
|
Boolean(price && parseInt(price) > 0) &&
|
||||||
|
Boolean(selectedReason) &&
|
||||||
|
(selectedReason !== '기타' || reason.trim() !== '')
|
||||||
|
|
||||||
|
const handlePriceChange = (value: string) => {
|
||||||
|
const numeric = value.replace(/\D/g, '')
|
||||||
|
if (numeric && parseInt(numeric) > MAX_PRICE) return
|
||||||
|
setPrice(numeric)
|
||||||
|
if (numeric) setPriceErrorMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRadioChange = (value: string) => {
|
||||||
|
setSelectedReason(value)
|
||||||
|
setRadioErrorMessage('')
|
||||||
|
if (value !== '기타') {
|
||||||
|
setReason('')
|
||||||
|
setOtherErrorMessage('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const koreanPrice = (() => {
|
||||||
|
if (!price) return '영'
|
||||||
|
const n = parseInt(price)
|
||||||
|
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
|
||||||
|
setRadioErrorMessage(!selectedReason ? '사유를 선택해주세요.' : '')
|
||||||
|
setOtherErrorMessage(selectedReason === '기타' && !reason.trim() ? '기타 사유를 입력해주세요.' : '')
|
||||||
|
if (isValid && !isLoading && !isSubmitted) {
|
||||||
|
const reasonPart =
|
||||||
|
selectedReason === '기타' ? `합의불가사유-기타-${reason.trim()}` : `합의불가사유-${selectedReason}`
|
||||||
|
const script = `공급희망가격-${price}, ${reasonPart}, 의견-${opinion.trim()}`
|
||||||
|
sendMessage(script, 'text')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = isLoading || isSubmitted
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
|
||||||
|
<AlertTriangle className="size-5 shrink-0" />
|
||||||
|
협상 합의 불가 사유 및 최종 제안 단가 입력
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[42vh] space-y-2 overflow-y-auto p-1 -m-1">
|
||||||
|
{/* 공급 희망 가격 */}
|
||||||
|
<div className="flex flex-col w-full gap-1">
|
||||||
|
<div className="flex w-full items-start gap-3">
|
||||||
|
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
|
||||||
|
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={cn(
|
||||||
|
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||||
|
isDisabled
|
||||||
|
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
||||||
|
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
||||||
|
)}
|
||||||
|
value={price ? parseInt(price).toLocaleString() : ''}
|
||||||
|
onChange={(e) => handlePriceChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<div className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{priceErrorMessage && (
|
||||||
|
<div className="pl-[112px] text-xs font-medium text-negative">{priceErrorMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 합의 불가 사유 (필수) */}
|
||||||
|
<div className="flex w-full gap-3">
|
||||||
|
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">합의 불가 사유</div>
|
||||||
|
<div className="flex w-full flex-col mt-2 gap-2">
|
||||||
|
{REASONS.map((r) => (
|
||||||
|
<SelectRadio
|
||||||
|
key={r.value}
|
||||||
|
text={r.text}
|
||||||
|
value={r.value}
|
||||||
|
name="rejectform"
|
||||||
|
selectedValue={selectedReason}
|
||||||
|
onChange={handleRadioChange}
|
||||||
|
errorMessage={radioErrorMessage}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<OtherReason
|
||||||
|
inputValue={reason}
|
||||||
|
setInputValue={(v) => {
|
||||||
|
if (selectedReason !== '기타') return
|
||||||
|
setReason(v)
|
||||||
|
if (v.trim()) setOtherErrorMessage('')
|
||||||
|
}}
|
||||||
|
errorMessage={otherErrorMessage}
|
||||||
|
selectedValue={selectedReason}
|
||||||
|
onChange={handleRadioChange}
|
||||||
|
isError={!!radioErrorMessage}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 의견 (선택) — 타결·결렬 공통 */}
|
||||||
|
<div className="flex w-full items-start gap-3">
|
||||||
|
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">의견</div>
|
||||||
|
<textarea
|
||||||
|
value={opinion}
|
||||||
|
onChange={(e) => setOpinion(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
maxLength={255}
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder="추가로 남길 의견이 있으면 작성해 주세요. (선택)"
|
||||||
|
className={cn(
|
||||||
|
'mt-2 w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||||
|
isDisabled
|
||||||
|
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
||||||
|
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-center mt-2">
|
||||||
|
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { cn } from '@/lib'
|
|||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
import { Percent, Price } from '@/features/chat/components/userInputs'
|
import { Percent, Price } from '@/features/chat/components/userInputs'
|
||||||
import { ExtraInfoBar } from '@/features/chat/components/ExtraInfoBar'
|
import { ExtraInfoBar } from '@/features/chat/components/ExtraInfoBar'
|
||||||
|
import { RejectForm } from '@/features/chat/components/RejectForm'
|
||||||
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
|
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
|
||||||
import type { UserButtonConfig } from '@/features/chat/types'
|
import type { UserButtonConfig } from '@/features/chat/types'
|
||||||
|
|
||||||
@ -28,6 +29,15 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 결렬 통일 폼 — 부가정보와 같은 자리(액션바)에서 전체폭으로.
|
||||||
|
if (type === 'reject') {
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 py-4 max-[1180px]:px-4">
|
||||||
|
<RejectForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
|
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
|
||||||
const isInputStep = type === 'percent' || type === 'price'
|
const isInputStep = type === 'percent' || type === 'price'
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,120 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { AlertTriangle } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib'
|
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
|
||||||
import { findRestoreScript, extractPart } from '@/features/chat/lib/rejectForm'
|
|
||||||
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
|
||||||
|
|
||||||
const MAX_PRICE = 999999999999999
|
|
||||||
|
|
||||||
// 재견적 실패 시: 최종 공급 희망 가격 + 배송 형태 입력 폼
|
|
||||||
export function RejectCM() {
|
|
||||||
const isLoading = useChatStore((s) => s.isLoading)
|
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage)
|
|
||||||
const messages = useChatStore((s) => s.messages)
|
|
||||||
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
|
|
||||||
const isVAT = item_vat_yn === 'VAT별도'
|
|
||||||
|
|
||||||
// 폼 값은 이전 제출 내역에서 1회만 복원 (setter 미사용 = lazy 초기화 전용)
|
|
||||||
const restored = useState(() => findRestoreScript(messages, 'rejectCM'))[0]
|
|
||||||
const [price, setPrice] = useState(() => extractPart(restored, '공급희망가격-'))
|
|
||||||
const [selectedReason, setSelectedReason] = useState(() => extractPart(restored, '배송형태-'))
|
|
||||||
const [priceErrorMessage, setPriceErrorMessage] = useState('')
|
|
||||||
const [radioErrorMessage, setRadioErrorMessage] = useState('')
|
|
||||||
|
|
||||||
// 제출 완료 여부는 messages 변화에 반응해야 함 (제출 후 갱신 반영)
|
|
||||||
const isSubmitted = useMemo(() => Boolean(findRestoreScript(messages, 'rejectCM')), [messages])
|
|
||||||
const isValid = Boolean(price && parseInt(price) > 0) && Boolean(selectedReason)
|
|
||||||
|
|
||||||
const handlePriceChange = (value: string) => {
|
|
||||||
const numeric = value.replace(/\D/g, '')
|
|
||||||
if (numeric && parseInt(numeric) > MAX_PRICE) return
|
|
||||||
setPrice(numeric)
|
|
||||||
if (numeric) setPriceErrorMessage('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const koreanPrice = (() => {
|
|
||||||
if (!price) return '영'
|
|
||||||
const n = parseInt(price)
|
|
||||||
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
|
|
||||||
})()
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
|
|
||||||
setRadioErrorMessage(!selectedReason ? '배송 형태를 선택해주세요.' : '')
|
|
||||||
if (isValid && !isLoading && !isSubmitted) {
|
|
||||||
sendMessage(`공급희망가격-${price}, 배송형태-${selectedReason}`, 'text')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDisabled = isLoading || isSubmitted
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
|
|
||||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
|
|
||||||
<AlertTriangle className="size-5 shrink-0" />
|
|
||||||
최종 공급 희망 가격 입력
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
|
|
||||||
최종 공급 희망 가격과 배송 형태를 입력하여 주시기 바랍니다.
|
|
||||||
<br />
|
|
||||||
가격 검토를 통해 경쟁력 있는 가격일 경우 다시 협상에 참여할 기회가 주어지오니 신중한 가격 제안 부탁드립니다.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col w-full gap-1">
|
|
||||||
<div className="flex w-full items-start gap-3">
|
|
||||||
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={cn(
|
|
||||||
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
|
||||||
isDisabled
|
|
||||||
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
|
||||||
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
|
||||||
)}
|
|
||||||
value={price ? parseInt(price).toLocaleString() : ''}
|
|
||||||
onChange={(e) => handlePriceChange(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()}
|
|
||||||
placeholder="0"
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
<div className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{priceErrorMessage && <div className="pl-[112px] text-xs font-medium text-negative">{priceErrorMessage}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full gap-3">
|
|
||||||
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">배송 형태</div>
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-x-6 gap-y-2">
|
|
||||||
{['협력사배송', '지정택배배송', '픽업배송'].map((opt) => (
|
|
||||||
<SelectRadio
|
|
||||||
key={opt}
|
|
||||||
text={opt === '협력사배송' ? '협력사 배송' : opt}
|
|
||||||
value={opt}
|
|
||||||
name="rejectcm"
|
|
||||||
selectedValue={selectedReason}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSelectedReason(v)
|
|
||||||
setRadioErrorMessage('')
|
|
||||||
}}
|
|
||||||
errorMessage={radioErrorMessage}
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-center mt-4">
|
|
||||||
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { AlertTriangle } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib'
|
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
|
||||||
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
|
||||||
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
|
|
||||||
import { findRestoreScript, restoreRejectRSP } from '@/features/chat/lib/rejectForm'
|
|
||||||
|
|
||||||
const MAX_PRICE = 999999999999999
|
|
||||||
|
|
||||||
const REASONS = [
|
|
||||||
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
|
|
||||||
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
|
|
||||||
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
|
|
||||||
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 협상 합의 불가 시: 최종 제안 단가 + 합의 불가 사유 입력 폼
|
|
||||||
export function RejectRSP() {
|
|
||||||
const isLoading = useChatStore((s) => s.isLoading)
|
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage)
|
|
||||||
const messages = useChatStore((s) => s.messages)
|
|
||||||
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
|
|
||||||
const isVAT = item_vat_yn === 'VAT별도'
|
|
||||||
|
|
||||||
// 폼 값은 이전 제출 내역에서 1회만 복원 (setter 미사용 = lazy 초기화 전용)
|
|
||||||
const restored = useState(() => restoreRejectRSP(findRestoreScript(messages, 'rejectRSP')))[0]
|
|
||||||
const [price, setPrice] = useState(restored.price)
|
|
||||||
const [selectedReason, setSelectedReason] = useState(restored.selectedReason)
|
|
||||||
const [reason, setReason] = useState(restored.reason)
|
|
||||||
const [otherErrorMessage, setOtherErrorMessage] = useState('')
|
|
||||||
const [priceErrorMessage, setPriceErrorMessage] = useState('')
|
|
||||||
const [radioErrorMessage, setRadioErrorMessage] = useState('')
|
|
||||||
|
|
||||||
const isSubmitted = useMemo(
|
|
||||||
() => Boolean(findRestoreScript(messages, 'rejectRSP')),
|
|
||||||
[messages],
|
|
||||||
)
|
|
||||||
const isValid =
|
|
||||||
Boolean(price && parseInt(price) > 0) &&
|
|
||||||
Boolean(selectedReason) &&
|
|
||||||
(selectedReason !== '기타' || reason.trim() !== '')
|
|
||||||
|
|
||||||
const handlePriceChange = (value: string) => {
|
|
||||||
const numeric = value.replace(/\D/g, '')
|
|
||||||
if (numeric && parseInt(numeric) > MAX_PRICE) return
|
|
||||||
setPrice(numeric)
|
|
||||||
if (numeric) setPriceErrorMessage('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRadioChange = (value: string) => {
|
|
||||||
setSelectedReason(value)
|
|
||||||
setRadioErrorMessage('')
|
|
||||||
if (value !== '기타') {
|
|
||||||
setReason('')
|
|
||||||
setOtherErrorMessage('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const koreanPrice = (() => {
|
|
||||||
if (!price) return '영'
|
|
||||||
const n = parseInt(price)
|
|
||||||
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
|
|
||||||
})()
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
|
|
||||||
setRadioErrorMessage(!selectedReason ? '사유를 선택해주세요.' : '')
|
|
||||||
setOtherErrorMessage(selectedReason === '기타' && !reason.trim() ? '기타 사유를 입력해주세요.' : '')
|
|
||||||
if (isValid && !isLoading && !isSubmitted) {
|
|
||||||
const script =
|
|
||||||
selectedReason === '기타'
|
|
||||||
? `공급희망가격-${price}, 합의불가사유-기타-${reason.trim()}`
|
|
||||||
: `공급희망가격-${price}, 합의불가사유-${selectedReason}`
|
|
||||||
sendMessage(script, 'text')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDisabled = isLoading || isSubmitted
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
|
|
||||||
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
|
|
||||||
<AlertTriangle className="size-5 shrink-0" />
|
|
||||||
협상 합의 불가 사유 및 최종 제안 단가 입력
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
|
|
||||||
협상이 합의에 도달하지 못하였습니다. 최종 희망하는 가격을 입력해주시기 바랍니다.
|
|
||||||
<br />
|
|
||||||
추가로 제시한 가격을 수용할 수 없는 이유를 선택하여 주시기 바라며, 목록에 없는 경우 기타 항목 선택 후 간단한 사유를
|
|
||||||
기재하여 주시기 바랍니다.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col w-full gap-1">
|
|
||||||
<div className="flex w-full items-start gap-3">
|
|
||||||
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={cn(
|
|
||||||
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
|
||||||
isDisabled
|
|
||||||
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
|
||||||
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
|
||||||
)}
|
|
||||||
value={price ? parseInt(price).toLocaleString() : ''}
|
|
||||||
onChange={(e) => handlePriceChange(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()}
|
|
||||||
placeholder="0"
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
<div className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{priceErrorMessage && <div className="pl-[112px] text-xs font-medium text-negative">{priceErrorMessage}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full gap-3">
|
|
||||||
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">합의 불가 사유</div>
|
|
||||||
<div className="flex w-full flex-col mt-2 gap-2">
|
|
||||||
{REASONS.map((r) => (
|
|
||||||
<SelectRadio
|
|
||||||
key={r.value}
|
|
||||||
text={r.text}
|
|
||||||
value={r.value}
|
|
||||||
name="rejectrsp"
|
|
||||||
selectedValue={selectedReason}
|
|
||||||
onChange={handleRadioChange}
|
|
||||||
errorMessage={radioErrorMessage}
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<OtherReason
|
|
||||||
inputValue={reason}
|
|
||||||
setInputValue={(v) => {
|
|
||||||
if (selectedReason !== '기타') return
|
|
||||||
setReason(v)
|
|
||||||
if (v.trim()) setOtherErrorMessage('')
|
|
||||||
}}
|
|
||||||
errorMessage={otherErrorMessage}
|
|
||||||
selectedValue={selectedReason}
|
|
||||||
onChange={handleRadioChange}
|
|
||||||
isError={!!radioErrorMessage}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-center mt-6">
|
|
||||||
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -16,6 +16,8 @@ export function deriveUserButtonConfig(
|
|||||||
const options = last.next_input_type
|
const options = last.next_input_type
|
||||||
|
|
||||||
if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT }
|
if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT }
|
||||||
|
// 결렬 폼(rejectRSP/rejectCM)은 통일된 RejectForm 을 액션바에서 받는다(메시지 카드 아님).
|
||||||
|
if (last.bot_chat_type === 'rejectRSP' || last.bot_chat_type === 'rejectCM') return { type: 'reject' }
|
||||||
// 타결 요약이 뜬 뒤의 '동의' 단계 = 부가정보 입력 + 동의를 한 자리(액션바)에서 받는다.
|
// 타결 요약이 뜬 뒤의 '동의' 단계 = 부가정보 입력 + 동의를 한 자리(액션바)에서 받는다.
|
||||||
// (요약 이후 confirm 단계에만 적용 — 재견적의 투찰확정/정보수정 같은 선택 단계는 그대로 둔다.)
|
// (요약 이후 confirm 단계에만 적용 — 재견적의 투찰확정/정보수정 같은 선택 단계는 그대로 둔다.)
|
||||||
const dealt = messages.some((m) => m.bot_chat_type === 'summaryRSP' || m.bot_chat_type === 'summaryCM')
|
const dealt = messages.some((m) => m.bot_chat_type === 'summaryRSP' || m.bot_chat_type === 'summaryCM')
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const initialState: ChatInitData = {
|
|||||||
quotation_memo: '',
|
quotation_memo: '',
|
||||||
quotation_end_time: '',
|
quotation_end_time: '',
|
||||||
custom: {},
|
custom: {},
|
||||||
|
labels: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export type UserButtonType =
|
|||||||
| 'three-black'
|
| 'three-black'
|
||||||
| 'price'
|
| 'price'
|
||||||
| 'extra-info'
|
| 'extra-info'
|
||||||
|
| 'reject'
|
||||||
| 'loading'
|
| 'loading'
|
||||||
| ''
|
| ''
|
||||||
|
|
||||||
@ -83,4 +84,5 @@ export type ChatInitData = {
|
|||||||
quotation_memo: string
|
quotation_memo: string
|
||||||
quotation_end_time: string
|
quotation_end_time: string
|
||||||
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
||||||
|
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
|
|||||||
for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '')
|
for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '')
|
||||||
return init
|
return init
|
||||||
})
|
})
|
||||||
|
const [opinion, setOpinion] = useState<string>(() => String(target.custom?.opinion ?? ''))
|
||||||
|
|
||||||
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
|
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
|
|||||||
if (f.type === 'boolean') out[f.key] = !!v
|
if (f.type === 'boolean') out[f.key] = !!v
|
||||||
else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v
|
else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v
|
||||||
}
|
}
|
||||||
|
if (opinion.trim()) out.opinion = opinion.trim()
|
||||||
onSubmit(out)
|
onSubmit(out)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@ -56,10 +58,7 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 p-5">
|
<div className="space-y-4 p-5">
|
||||||
{fields.length === 0 ? (
|
{fields.map((f) => (
|
||||||
<p className="py-6 text-center text-sm text-neutral-60">입력할 부가정보 항목이 없습니다.</p>
|
|
||||||
) : (
|
|
||||||
fields.map((f) => (
|
|
||||||
<div key={f.key} className="space-y-1.5">
|
<div key={f.key} className="space-y-1.5">
|
||||||
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
|
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
|
||||||
{f.type === 'boolean' ? (
|
{f.type === 'boolean' ? (
|
||||||
@ -99,8 +98,20 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
)}
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-semibold text-neutral-80">
|
||||||
|
의견 <span className="font-normal text-neutral-50">(선택)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={opinion}
|
||||||
|
onChange={(e) => setOpinion(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
maxLength={255}
|
||||||
|
placeholder="추가로 남길 의견"
|
||||||
|
className="w-full resize-none rounded-xl border border-border bg-white px-3 py-2 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 border-t border-border p-5">
|
<div className="flex gap-2 border-t border-border p-5">
|
||||||
@ -114,7 +125,6 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
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"
|
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white hover:bg-brand-700 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user