o2o-negosium-original/frontend/src/features/chat/components/RejectForm.tsx

188 lines
8.6 KiB
TypeScript

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