feat(front): 협상 채팅 페이지 (mock UI)

- KT-NEGOWIZ /chat 을 현재 아키텍처(Vite·React Router·feature 구조)로 이식
- 사이드 ItemSection(+이미지 확대), 헤더 잔여시간, 채팅(메시지/템플릿/입력), 메뉴 섹션
- 메시지 템플릿: 협상 성공률·협상요약·투찰요약·재협상/재견적 폼
- zustand 스토어 + mock 데이터(정적 쇼케이스, 제출 시 로컬 append)
- Next.js·KT 의존 제거(next/image·navigation, 로고·서비스명·연락처, 팝업 보류)
- ChatPage 배선: ChatContainer / ItemSection / RemainingTime + auth SidebarFooter 재사용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-17 14:38:04 +09:00
parent 54b44c0877
commit 147f9dd007
32 changed files with 1921 additions and 5 deletions

View File

@ -0,0 +1,115 @@
import { useEffect, useRef, memo } from 'react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary'
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
export function ChatMessage() {
return (
<div className="flex-1 w-full min-h-0 pr-[16px] pt-[64px]">
<div className="chat-scroll h-full overflow-y-auto flex flex-col pl-[140px] pr-[126px]">
<ChatList />
</div>
</div>
)
}
function ChatList() {
const bottomRef = useRef<HTMLDivElement | null>(null)
const chats = useChatStore((s) => s.messages)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chats])
if (!chats || chats.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="body-1 text-neutral-70"> .</p>
</div>
)
}
return (
<div className="flex flex-col w-full">
{chats.map((message, index) => (
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
))}
<div ref={bottomRef} />
</div>
)
}
const MessageItem = memo(function MessageItem({
message,
isFirst,
messages,
currentIndex,
}: {
message: ChatMessageType
isFirst: boolean
messages: ChatMessageType[]
currentIndex: number
}) {
const isBot = message.sender === 'bot'
// 직전이 reject 폼이면 사용자 답변은 숨기고 구분선만 표시
if (!isBot && currentIndex > 0) {
const prev = messages[currentIndex - 1]
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
return (
<>
<div className="text-right mb-[56px]" />
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
}
}
return <div>{isBot ? <BotMessage message={message} isFirst={isFirst} /> : <UserMessage text={message.script || ''} />}</div>
})
const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) {
return (
<div className="mb-[56px]">
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
<div className="body-1-read-r">{message.script || ''}</div>
</div>
<div className="flex flex-col gap-4 w-full mt-[32px]">
{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 === 'summaryCM' && message.summary && (
<BidSummary
itemName={message.summary.item_name}
itemCode={message.summary.item_code}
bidPrice={message.summary.final_price}
deliveryType={message.summary.delivery_type || ''}
isVAT={message.summary.item_isVAT}
/>
)}
{message.bot_chat_type === 'rejectRSP' && <RejectRSP />}
{message.bot_chat_type === 'rejectCM' && <RejectCM />}
</div>
</div>
)
})
const UserMessage = memo(function UserMessage({ text }: { text: string }) {
return (
<>
<div className="text-right mb-[56px]">
<div className="inline-block max-w-[87%] bg-neutral-40 text-neutral-90 text-lg leading-[150%] tracking-[-0.18px] px-[20px] py-[10px] rounded-full break-keep">
{text}
</div>
</div>
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
})

View File

@ -0,0 +1,13 @@
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { ChatMessage } from '@/features/chat/components/ChatMessage'
import { UserButton } from '@/features/chat/components/UserButton'
export function ChatSection() {
const { userButtonConfig } = useChatStore()
return (
<div className="flex flex-1 flex-col w-full h-full bg-surface rounded-bl-[8px]">
<ChatMessage />
<UserButton {...userButtonConfig} />
</div>
)
}

View File

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ImageIcon, X } from 'lucide-react'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
export function ItemImage() {
const { item_image } = useChatInitStore()
const [isOpen, setIsOpen] = useState(false)
return (
<div className="flex flex-col w-full items-center justify-center pt-16 pr-8 pb-8 pl-8">
<button
type="button"
onClick={() => setIsOpen(true)}
aria-label="이미지 크게 보기"
className="relative cursor-pointer group"
>
<Thumb src={item_image} />
<div className="absolute inset-0 flex items-center justify-center rounded-[16px] bg-neutral-40 opacity-0 transition-opacity duration-300 group-hover:opacity-90">
<span className="text-neutral-00 text-base font-medium"> </span>
</div>
</button>
{isOpen && <ImageModal src={item_image} onClose={() => setIsOpen(false)} />}
</div>
)
}
function Thumb({ src }: { src: string }) {
if (src) {
return <img src={src} alt="상품 이미지" className="rounded-[16px] w-[220px] h-[220px] object-cover" />
}
return (
<div className="flex w-[220px] h-[220px] items-center justify-center rounded-[16px] bg-neutral-20 text-neutral-60">
<ImageIcon size={48} strokeWidth={1.5} />
</div>
)
}
function ImageModal({ src, onClose }: { src: string; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="상품 이미지 확대"
>
<div className="relative" onClick={(e) => e.stopPropagation()}>
{src ? (
<img src={src} alt="상품 이미지" className="rounded-[16px] max-w-[42rem] max-h-[90vh] object-contain" />
) : (
<div className="flex w-[60vmin] h-[60vmin] max-w-[42rem] max-h-[90vh] items-center justify-center rounded-[16px] bg-neutral-20 text-neutral-60">
<ImageIcon size={96} strokeWidth={1.25} />
</div>
)}
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-white border border-neutral-30 text-neutral-80 cursor-pointer hover:bg-neutral-10 transition-colors"
>
<X size={20} />
</button>
</div>
</div>,
document.body,
)
}

View File

@ -0,0 +1,128 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib'
import { ItemImage } from '@/features/chat/components/ItemImage'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
export function ItemSection() {
return (
<div className="flex flex-col w-full flex-1 overflow-hidden min-h-0">
<ItemImage />
<ItemInfo />
</div>
)
}
interface TooltipState {
text: string
x: number
y: number
above?: boolean
}
function Tooltip({ text, x, y, above }: TooltipState) {
return createPortal(
<div
className="fixed z-[9999] pointer-events-none"
style={{ left: x, top: y, transform: above ? 'translateY(-100%)' : undefined }}
>
<div className="px-3 py-2 mb-1 rounded-lg bg-neutral-20 text-neutral-90 text-[11px] leading-[16px] whitespace-pre-wrap break-keep max-w-[240px] shadow-md border border-neutral-30">
{text}
</div>
</div>,
document.body,
)
}
function ItemInfo() {
const {
item_code,
item_price,
item_vat_yn,
item_model_name,
item_maker_name,
item_min_order_quantity,
item_lead_time,
item_spec,
item_name,
} = useChatInitStore()
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const showTooltip = useCallback((e: React.MouseEvent, text: string, above = false) => {
if (hideTimer.current) clearTimeout(hideTimer.current)
if (above) {
const rect = e.currentTarget.getBoundingClientRect()
setTooltip({ text, x: rect.left, y: rect.top - 8, above })
} else {
setTooltip({ text, x: e.clientX, y: e.clientY + 16, above })
}
}, [])
const hideTooltip = useCallback(() => {
hideTimer.current = setTimeout(() => setTooltip(null), 100)
}, [])
useEffect(() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current)
}, [])
const formatPrice = (price: number) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const isNewItem = !item_code && !item_price
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}`
const formattedPrice = `${priceText}(${item_vat_yn || 'VAT별도'})`
const renderRow = (title: string, data: string, important = false) => {
const textClass = important ? 'title-3 text-neutral-90' : 'body-3 text-neutral-70'
const displayData = data || '-'
return (
<div className="flex items-start self-stretch gap-1 min-w-0">
<div className={cn(textClass, 'w-[100px]')}>{title}</div>
<div
className={cn(textClass, 'w-[150px] break-keep cursor-default')}
onMouseEnter={(e) => displayData !== '-' && showTooltip(e, displayData)}
onMouseLeave={hideTooltip}
>
{displayData}
</div>
</div>
)
}
return (
<div className="flex flex-col w-full flex-1 overflow-hidden min-h-0">
<div
className="text-center headline-3 text-neutral-90 mb-4 truncate px-8 flex-shrink-0 cursor-default"
onMouseEnter={(e) => item_name && showTooltip(e, item_name)}
onMouseLeave={hideTooltip}
>
{item_name}
</div>
<div className="flex flex-1 items-start self-stretch mr-2 overflow-y-auto pr-6 pl-8 min-h-0">
<div className="flex flex-col items-start self-stretch flex-[1_0_auto] gap-2 min-w-0">
{renderRow('상품코드', item_code, true)}
{renderRow('단가', formattedPrice, true)}
{renderRow('모델명', item_model_name, true)}
{renderRow('제조사', item_maker_name)}
{renderRow('최소주문수량', item_min_order_quantity)}
{renderRow('리드타임', item_lead_time)}
<div className="flex items-start self-stretch gap-1 flex-[1_0]">
<div className="body-3 text-neutral-70 w-[100px]"></div>
<div
className="body-3 text-neutral-70 self-stretch w-0 flex-[1_0] overflow-hidden whitespace-normal break-words break-keep cursor-default"
onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)}
onMouseLeave={hideTooltip}
>
{item_spec || '-'}
</div>
</div>
</div>
</div>
{tooltip && <Tooltip {...tooltip} />}
</div>
)
}

View File

@ -0,0 +1,18 @@
import { useState, useEffect } from 'react'
import { getTimeRemaining } from '@/features/chat/lib/remainingTime'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 헤더의 협상 잔여 시간. 1초마다 tick 을 올려 렌더 중 남은 시간을 다시 계산한다.
export function RemainingTime() {
const { quotation_end_time } = useChatInitStore()
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const remaining = quotation_end_time ? getTimeRemaining(quotation_end_time) : '-'
return <span className="title-1"> : {remaining}</span>
}

View File

@ -0,0 +1,99 @@
import { useNavigate } from 'react-router'
import { List, Loader2 } from 'lucide-react'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { Percent, Price } from '@/features/chat/components/userInputs'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import type { UserButtonConfig } from '@/features/chat/types'
const style = {
goToList:
'flex w-[159px] h-[48px] bg-neutral-40 hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-neutral-80 cursor-pointer gap-2 transition-all ease-out hover:scale-[1.01]',
black:
'flex min-w-[120px] px-[32px] h-[48px] bg-primary hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-primary-foreground cursor-pointer whitespace-nowrap transition-all duration-200 ease-out hover:scale-[1.01]',
gray: 'flex min-w-[120px] px-[32px] h-[48px] bg-neutral-60 rounded-[999px] items-center justify-center title-2 text-neutral-00 cursor-pointer whitespace-nowrap transition-all ease-out hover:scale-[1.01]',
white:
'flex min-w-[120px] px-[32px] h-[48px] bg-neutral-00 rounded-[999px] items-center justify-center title-2 text-neutral-80 border border-neutral-80 cursor-pointer whitespace-nowrap transition-all ease-out hover:scale-[1.01]',
}
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
if (type === '') return null
return (
<div className="flex w-full pb-[52px]">
<div className="grid grid-cols-[1fr_auto_1fr] items-center w-full">
<div />
<div className="flex justify-center">
{type === 'one-black' && <OneBlack text={text || '확인'} />}
{type === 'one-gray' && <OneGray text={text || '확인'} />}
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
{type === 'percent' && <Percent />}
{type === 'three-black' && (
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
)}
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <Loader2 className="size-8 animate-spin text-neutral-60" />}
</div>
<div className="flex justify-end">
<GoToList />
</div>
</div>
</div>
)
}
function GoToList() {
const navigate = useNavigate()
return (
<button className={style.goToList} onClick={() => navigate('/list')} aria-label="상품 목록으로 이동">
<List size={24} />
<span> </span>
</button>
)
}
function OneBlack({ text }: { text: string }) {
const navigate = useNavigate()
const sendMessage = useChatStore((s) => s.sendMessage)
const onClick = () => (text === GO_TO_LIST_TEXT ? navigate('/list') : sendMessage(text))
return (
<button className={style.black} onClick={onClick}>
{text}
</button>
)
}
function OneGray({ text }: { text: string }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<button className={style.gray} onClick={() => sendMessage(text)}>
{text}
</button>
)
}
function ThreeBlack({ textList }: { textList: [string, string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<div className="flex gap-3">
{textList.map((t) => (
<button key={t} className={style.black} onClick={() => sendMessage(t)}>
{t}
</button>
))}
</div>
)
}
function BlackWhite({ textList }: { textList: [string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<div className="flex gap-3">
<button className={style.black} onClick={() => sendMessage(textList[0])}>
{textList[0]}
</button>
<button className={style.white} onClick={() => sendMessage(textList[1])}>
{textList[1]}
</button>
</div>
)
}

View File

@ -0,0 +1,22 @@
import { interactive, cn } from '@/lib'
// 헬프데스크 (TODO: 이용가이드 팝업 연동, 연락처는 추후 설정값으로 교체)
export function Contact() {
return (
<div className="flex flex-col w-full pl-[24px] pb-[24px]">
<div className="flex flex-col w-full gap-2">
<div className="title-5 text-neutral-80"> </div>
<button
type="button"
className={cn('body-5 text-neutral-80 bg-neutral-40 rounded-[8px] px-[12px] py-[6px] w-fit', interactive)}
>
</button>
<div className="flex flex-col gap-1">
<div className="body-5 text-neutral-60">-</div>
<div className="body-5 text-neutral-60">-</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import { ChevronRight } from 'lucide-react'
import { interactive, cn } from '@/lib'
// 유의사항 및 이용방법 (TODO: 이용가이드 팝업 연동)
export function Guide() {
return (
<button
type="button"
className={cn(
'flex w-full bg-white rounded-[28px] py-[20px] pl-[24px] pr-[16px] justify-between items-center text-left',
interactive,
)}
>
<span className="menu-title text-neutral-80 break-keep"> </span>
<ChevronRight size={20} className="text-neutral-70" />
</button>
)
}

View File

@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
interface Props {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}
export function MDInformation({ isOpen, setIsOpen }: Props) {
const { quotation_memo } = useChatInitStore()
const memoArray = useMemo(() => {
if (!quotation_memo?.trim()) return []
return quotation_memo
.split(/\r?\n+/)
.map((s) => s.trim())
.filter(Boolean)
}, [quotation_memo])
if (memoArray.length === 0) return null
return (
<div className="flex flex-col w-full min-w-[200px]">
<div className="bg-white rounded-[28px]">
<div className={cn('flex items-center w-full h-[4.75rem] px-4 pl-6', isOpen ? 'pt-6 pb-3' : 'py-6')}>
<div className="flex items-center flex-1 justify-between">
<div className="menu-title text-neutral-80">MD </div>
<button
type="button"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
>
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
</button>
</div>
</div>
<div
className={cn(
'flex flex-col items-start gap-[19px] w-full px-6 overflow-hidden transition-all duration-300 ease-in-out',
isOpen ? 'max-h-[200px] pb-8 opacity-100' : 'max-h-0 pb-0 opacity-0',
)}
>
<div className={cn('flex flex-col gap-2 items-start self-stretch max-h-[200px] break-keep', isOpen ? 'overflow-y-auto' : 'overflow-hidden')}>
{memoArray.map((item, i) => (
<div key={i} className="flex items-start gap-2 body-3 text-neutral-70">
<span className="flex-shrink-0"></span>
<div className="flex-1 whitespace-pre-wrap">{linkText(item)}</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
function linkText(text: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const isUrl = (s: string) => /^https?:\/\/[^\s]+$/.test(s)
const parts = text.split(urlRegex)
return (
<div className="body-3 text-neutral-70">
{parts.map((part, i) =>
isUrl(part) ? (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="block text-info underline cursor-pointer hover:brightness-95 transition-all"
>
</a>
) : (
<span key={i}>{i > 0 && isUrl(parts[i - 1]) ? part.replace(/^(\s)/, '') : part}</span>
),
)}
</div>
)
}

View File

@ -0,0 +1,21 @@
import { useState } from 'react'
import { MDInformation } from '@/features/chat/components/menu/MDInformation'
import { NegoStep } from '@/features/chat/components/menu/NegoStep'
import { Guide } from '@/features/chat/components/menu/Guide'
import { Contact } from '@/features/chat/components/menu/Contact'
export function MenuSection() {
const [isMDOpen, setIsMDOpen] = useState(true)
const [isStepOpen, setIsStepOpen] = useState(true)
return (
<div className="flex flex-col w-[360px] h-full pt-[64px] pr-[24px]">
<div className="flex flex-col flex-1 gap-[28px]">
<MDInformation isOpen={isMDOpen} setIsOpen={setIsMDOpen} />
<NegoStep isOpen={isStepOpen} setIsOpen={setIsStepOpen} />
<Guide />
</div>
<Contact />
</div>
)
}

View File

@ -0,0 +1,52 @@
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
interface Props {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}
const STEPS = ['서비스안내', '담당자확인', '협상품목안내', '가격협상', '협상종료']
export function NegoStep({ isOpen, setIsOpen }: Props) {
const chats = useChatStore((s) => s.messages)
const currentStep = chats[chats.length - 1]?.display_step || '서비스안내'
return (
<div className="flex flex-col w-full min-w-[200px]">
<div className="bg-white rounded-[28px]">
<div className={cn('flex items-center w-full h-[4.75rem] px-4 pl-6', isOpen ? 'pt-6 pb-3' : 'py-6')}>
<div className="flex items-center flex-1 justify-between">
<div className="menu-title text-neutral-80"></div>
<button
type="button"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
>
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
</button>
</div>
</div>
<div
className={cn(
'flex flex-col items-start gap-[19px] w-full px-6 overflow-hidden transition-all duration-300 ease-in-out',
isOpen ? 'max-h-[200px] pb-8 opacity-100' : 'max-h-0 pb-0 opacity-0',
)}
>
<div className="flex flex-col gap-2 items-start self-stretch break-keep">
{STEPS.map((step, i) => (
<div key={step} className="flex items-start gap-2">
<div className={cn('body-3 text-neutral-80', currentStep === step && 'font-bold')}>
{i + 1}. {step}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,61 @@
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
interface BidSummaryProps {
itemName: string
itemCode: string
bidPrice: number
deliveryType: string
isVAT: boolean
}
// 투찰 결과 요약 카드
export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }: BidSummaryProps) {
return (
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-4">
<h1 className="headline-3 text-neutral-90"> </h1>
<BidItem title="상품명" value={itemName || '-'} />
<BidItem title="상품 코드" value={itemCode || '-'} />
<BidPrice title="투찰 가격" number={bidPrice || 0} isVAT={isVAT} />
<BidItem title="배송형태" value={deliveryType || '-'} />
</div>
)
}
function BidItem({ title, value }: { title: string; value: string }) {
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1 whitespace-pre-wrap">
<span className="body-1-read-b">{title} :</span>
<div className="flex body-1-read-r">&nbsp;{value}</div>
</div>
</div>
)
}
function BidPrice({ number, isVAT, title }: { number: number; isVAT: boolean; title: string }) {
const numberString = number.toLocaleString()
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1">
<div className="body-1-read-b flex flex-shrink-0">{title} :</div>
<div className="flex flex-1 flex-wrap">
<span className="body-1-read-b text-negative break-keep">&nbsp;{numberString}</span>
<span className="body-1-read-b text-neutral-90 break-keep">
&nbsp;({numberToKorean(parseInt(numberString.replace(/,/g, '')))})
</span>
<span className="body-1-read-b text-neutral-90 break-keep">&nbsp;{isVAT ? 'VAT포함' : 'VAT별도'}</span>
</div>
</div>
</div>
)
}
function Dot() {
return (
<div className="flex justify-end items-center w-[26px] h-[30px]">
<p className="body-1-read-r"></p>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { cn } from '@/lib'
// 협상 성공률 바 (0~100%). 구간별 색: 빨강(~30) / 주황(~70) / 초록(70~)
function selectBgColor(index: number, currentStep: number): string {
let fill = '#E71C3B'
if (currentStep > 3 && currentStep <= 7) fill = '#FDB21C'
if (currentStep > 7) fill = '#16BD14'
return index <= currentStep ? fill : '#ddd'
}
function UnitBlock({ blockIndex, percent }: { blockIndex: number; percent: number }) {
const minBlockPercent = blockIndex * 10
const maxBlockPercent = (blockIndex + 1) * 10
const remindCount = percent - minBlockPercent
let nodeType: 'fill' | 'empty' | 'half' = 'empty'
if (maxBlockPercent < percent) nodeType = 'fill'
else if (minBlockPercent <= percent) nodeType = 'half'
return (
<div className="flex flex-col items-center flex-1">
<div className="flex w-full h-3">
{Array.from({ length: 10 }).map((_, i) => {
let color = selectBgColor(blockIndex, Math.floor(percent / 10))
if (nodeType === 'half' && i >= remindCount) color = '#ddd'
const subBlockPercent = blockIndex * 10 + i + 1
const isTarget = subBlockPercent === percent
const isLeftAlign = percent < 90
return (
<div key={i} className="flex-1 h-full rounded-[1px] relative" style={{ backgroundColor: color }}>
{isTarget && (
<div className="pointer-events-none absolute bottom-full -top-4 left-1/2 -translate-x-1/2 z-10">
<div className="relative w-0 h-0">
<div className="absolute -top-2 left-[calc(50%+4px)] -translate-x-1/2 w-0 h-0 border-l-[8px] border-r-[8px] border-t-[14px] border-l-transparent border-r-transparent border-t-black" />
<span
className={cn(
'absolute top-[calc(50%-2px)] -translate-y-1/2 text-[24px] leading-none font-bold tabular-nums text-black whitespace-nowrap',
isLeftAlign ? 'left-[18px]' : 'right-[10px]',
)}
>
{percent}%
</span>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export function Indicator({ number }: { number: number }) {
const percent = Math.min(Math.max(number, 0), 100)
return (
<div className="flex w-full flex-col gap-3 bg-neutral-00 rounded-[28px] px-10 py-[36px]">
<h1 className="title-1 text-neutral-90"> </h1>
<div className="flex flex-col gap-2">
{percent === 0 ? (
<div className="relative h-6">
<div className="absolute left-[20px] -translate-x-1/2 text-xl text-black font-bold">&nbsp;0%</div>
</div>
) : (
<div className="h-6" />
)}
<div className="flex gap-1">
{Array.from({ length: 10 }).map((_, i) => (
<UnitBlock key={i} blockIndex={i} percent={percent} />
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react'
import { cn } from '@/lib'
// RejectRSP 의 '기타' 사유: 라디오 + 자동 높이 textarea
export function OtherReason({
inputValue,
setInputValue,
errorMessage,
selectedValue,
onChange,
isError,
disabled,
}: {
inputValue: string
setInputValue: (value: string) => void
errorMessage: string
selectedValue: string
onChange: (value: string) => void
isError: boolean
disabled?: boolean
}) {
const isChecked = selectedValue === '기타'
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const ta = ref.current
if (!ta) return
ta.style.height = 'auto'
const maxHeight = 20 * 3 + 16
if (ta.scrollHeight > maxHeight) {
ta.style.height = `${maxHeight}px`
ta.style.overflowY = 'auto'
} else {
ta.style.height = `${ta.scrollHeight}px`
ta.style.overflowY = 'hidden'
}
}, [inputValue, isChecked])
const isTextareaDisabled = disabled || !isChecked
return (
<div className="flex flex-col gap-1">
<div className="flex items-start gap-2">
<label className={cn('flex items-start gap-2 flex-shrink-0', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
<input
className={cn(
'appearance-none w-[16px] h-[16px] mt-[10px] flex-shrink-0 rounded-full border-[1.5px]',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
isChecked
? 'border-neutral-70 bg-[radial-gradient(circle,var(--neutral-70)_40%,white_40%)]'
: isError
? 'border-negative bg-white'
: 'border-neutral-60 bg-white',
)}
type="radio"
name="rejectrsp"
value="기타"
checked={isChecked}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<div className={cn('reject break-keep whitespace-nowrap mt-[4px] flex', disabled && 'text-neutral-60')}>
</div>
</label>
<textarea
ref={ref}
className={cn(
'w-full px-3 py-2 body-3 resize-none border border-neutral-40 rounded-[8px] outline-none transition-all duration-200 placeholder:text-neutral-60 focus:border-neutral-70 focus:outline-none',
isTextareaDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
errorMessage && 'border-negative',
)}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요."
disabled={isTextareaDisabled}
rows={1}
style={{ minHeight: '35px', lineHeight: '20px' }}
/>
</div>
{errorMessage && <div className="body-5 text-negative pl-[65px]">{errorMessage}</div>}
</div>
)
}

View File

@ -0,0 +1,115 @@
import { useState, useMemo } from '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-10 bg-neutral-00 rounded-[28px] gap-2 transition-all duration-300">
<div className="flex w-full h-[48px] bg-[#CD2A2D] headline-3 text-neutral-00 rounded-[12px] items-center justify-center">
</div>
<div className="reject break-keep py-2">
.
<br />
.
</div>
<div className="flex flex-col w-full gap-1">
<div className="flex w-full items-start gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]"> </div>
<div className="flex flex-wrap w-full items-center pt-2">
<div className="flex items-center gap-2">
<input
type="text"
className={cn(
'text-right max-w-[220px] h-full max-h-[35px] border p-2 body-3 placeholder:text-neutral-60 focus:border-neutral-70 rounded-[8px] outline-none border-neutral-40',
isDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isDisabled}
/>
<div className="reject whitespace-nowrap mr-2">{isVAT && '(VAT 별도)'}</div>
</div>
<div className="reject-gray whitespace-nowrap">[{koreanPrice} ]</div>
</div>
</div>
{priceErrorMessage && <div className="body-5 text-negative pl-[126px]">{priceErrorMessage}</div>}
</div>
<div className="flex w-full gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]"> </div>
<div className="flex w-full items-center justify-between pr-20 flex-wrap">
{['협력사배송', '지정택배배송', '픽업배송'].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>
)
}

View File

@ -0,0 +1,155 @@
import { useState, useMemo } from '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-10 bg-neutral-00 rounded-[28px] gap-2 transition-all duration-300">
<div className="flex w-full h-[48px] bg-[#CD2A2D] headline-3 text-neutral-00 rounded-[12px] items-center justify-center">
</div>
<div className="reject break-keep py-2">
. .
<br />
,
.
</div>
<div className="flex flex-col w-full gap-1">
<div className="flex w-full items-start gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]"> </div>
<div className="flex flex-wrap w-full items-center pt-2">
<div className="flex items-center gap-2">
<input
type="text"
className={cn(
'text-right max-w-[220px] h-full max-h-[35px] border p-2 body-3 placeholder:text-neutral-60 focus:border-neutral-70 rounded-[8px] outline-none border-neutral-40',
isDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isDisabled}
/>
<div className="reject whitespace-nowrap mr-2">{isVAT && '(VAT 별도)'}</div>
</div>
<div className="reject-gray whitespace-nowrap">[{koreanPrice} ]</div>
</div>
</div>
{priceErrorMessage && <div className="body-5 text-negative pl-[126px]">{priceErrorMessage}</div>}
</div>
<div className="flex w-full gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 pb-2 flex-shrink-0 w-[110px]"> </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}
/>
</div>
</div>
<div className="flex w-full items-center justify-center mt-6">
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
</div>
</div>
)
}

View File

@ -0,0 +1,91 @@
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import type { ChatSummary } from '@/features/chat/types'
// 협상 결과 요약 카드
export function Summary({ data }: { data: ChatSummary }) {
return (
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-4">
<h1 className="headline-3 text-neutral-90"> </h1>
<div className="body-1-read-r">
.
.
</div>
<div className="flex flex-col">
<div className="body-1-read-r"> : {data.nego_start_date || '-'}</div>
<div className="body-1-read-r"> : {data.nego_end_date || '-'}</div>
<div className="body-1-read-r">
: {data.supplier_name || '-'} ({data.md_phone_number || '-'}){' '}
{data.supplier_manager_email || '-'}
</div>
<div className="body-1-read-r"> </div>
</div>
<div className="flex flex-col gap-4">
<DetailText title="상품 코드" value={data.item_code || '-'} />
<DetailText title="상품 명" value={data.item_name || '-'} />
<DetailText title="모델 명" value={data.item_model || '-'} />
<DetailText title="제품 규격" value={data.item_spec || '-'} />
<DetailText title="최소 주문" value={data.item_moq || '-'} />
<DetailText title="배송 형태" value={data.item_delivery_type || '-'} />
<DetailText title="배송 리드타임" value={data.item_lead_time || '-'} />
<PriceText price={data.final_price} isVAT={data.item_isVAT} />
</div>
<div className="flex flex-col">
<div className="body-1-read-r">
기간: 협상 1 ({data.nego_end_date ? addOneYear(data.nego_end_date) : '-'})
</div>
<div className="body-1-read-r">
MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
</div>
</div>
<div className="body-1-read-b"> .</div>
</div>
)
}
function addOneYear(dateStr: string): string {
const match = dateStr.match(/(\d{4})년 (\d{2})월 (\d{2})일 (\d{2})시 (\d{2})분/)
if (!match) return '-'
const [, year, month, day, hour, minute] = match
const date = new Date(parseInt(year) + 1, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute))
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
}
function DetailText({ title, value }: { title: string; value: string }) {
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex-1 whitespace-pre-wrap">
<span className="body-1-read-b">{title} :&nbsp;</span>
<span className="body-1-read-r">{value}</span>
</div>
</div>
)
}
function PriceText({ price, isVAT }: { price: number; isVAT: boolean }) {
const numberString = price.toLocaleString()
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1">
<div className="body-1-read-b flex flex-shrink-0"> :</div>
<div className="flex flex-1 flex-wrap">
<span className="body-1-read-b text-negative break-keep">&nbsp;{numberString}</span>
<span className="body-1-read-b text-neutral-90 break-keep">
&nbsp;({numberToKorean(parseInt(numberString.replace(/,/g, '')))})
</span>
<span className="body-1-read-b text-neutral-90 break-keep">&nbsp;{isVAT ? 'VAT포함' : 'VAT별도'}</span>
</div>
</div>
</div>
)
}
function Dot() {
return (
<div className="flex justify-end items-center w-[26px] h-[30px]">
<p className="body-1-read-r"></p>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib'
// reject 폼 공유 컨트롤 (RejectCM / RejectRSP 공용)
export function SelectRadio({
text,
value,
name,
selectedValue,
onChange,
errorMessage,
disabled,
}: {
text: string
value: string
name: string
selectedValue: string
onChange: (value: string) => void
errorMessage: string
disabled?: boolean
}) {
const isChecked = selectedValue === value
return (
<label className={cn('flex items-center gap-2', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
<input
className={cn(
'appearance-none w-[16px] h-[16px] min-w-[16px] min-h-[16px] rounded-full border-[1.5px] whitespace-nowrap',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
isChecked
? 'border-neutral-70 bg-[radial-gradient(circle,var(--neutral-70)_40%,white_40%)]'
: errorMessage
? 'border-negative bg-white'
: 'border-neutral-60 bg-white',
)}
type="radio"
name={name}
value={value}
checked={isChecked}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<div className={cn('reject break-keep', disabled && 'text-neutral-60')}>{text}</div>
</label>
)
}
export function SubmitButton({
isValid,
isLoading,
isSubmitted,
onSubmit,
}: {
isValid: boolean
isLoading: boolean
isSubmitted: boolean
onSubmit: () => void
}) {
if (isSubmitted) return null
const style = isLoading
? 'bg-neutral-40 text-neutral-80 cursor-not-allowed'
: isValid
? 'bg-neutral-90 text-white cursor-pointer hover:bg-neutral-80'
: 'bg-neutral-20 text-neutral-60 cursor-not-allowed'
return (
<button
className={cn(
'flex items-center justify-center w-[280px] h-[48px] rounded-[12px] text-base font-bold tracking-[-0.32px] transition-all duration-200',
style,
)}
onClick={onSubmit}
disabled={!isValid || isLoading}
>
{isLoading ? <Loader2 className="size-5 animate-spin" /> : '제출'}
</button>
)
}

View File

@ -0,0 +1,157 @@
import { useState, useRef, useId } from 'react'
import { useChatStore } from '@/features/chat/stores/useChatStore'
// 단위(원/%) 오버레이 + 전송 버튼이 붙은 값 입력 (Percent / Price 공용)
function InputWithUnit({
value,
onChange,
onSubmit,
placeholder,
unit,
error,
inputRef,
ariaLabel,
}: {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onSubmit: () => void
placeholder: string
unit: string
error: string
inputRef: React.RefObject<HTMLInputElement | null>
ariaLabel: string
}) {
const errorId = useId()
return (
<div className="relative flex flex-col gap-3">
<div className="relative flex items-center min-w-[480px]">
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={onChange}
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
className="h-[50px] flex-1 bg-white border-none rounded-[999px] outline-none ring-1 ring-primary headline-3 text-negative placeholder:text-neutral-60 pl-[32px]"
placeholder={placeholder}
aria-label={ariaLabel}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
<div className="absolute left-[32px] headline-3 text-neutral-70 pointer-events-none">
<span className="opacity-0">{value}</span>
{value && <span className="ml-1">{unit}</span>}
</div>
<button
type="button"
onClick={onSubmit}
className="absolute right-0 flex items-center justify-center min-w-[120px] h-[50px] px-[32px] bg-primary hover:brightness-[0.97] rounded-[999px] title-2 text-primary-foreground cursor-pointer whitespace-nowrap transition-colors duration-200"
aria-label="전송"
>
</button>
</div>
{error && (
<div id={errorId} role="alert" className="absolute top-[62px] left-1/2 -translate-x-1/2 title-2 text-negative w-full text-center">
{error}
</div>
)}
</div>
)
}
export function Percent() {
const [percent, setPercent] = useState('')
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const sendMessage = useChatStore((s) => s.sendMessage)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (value === '') {
setPercent('')
setError('')
return
}
if (!/^\d*\.?\d*$/.test(value)) return
if (value.includes('.')) {
const parts = value.split('.')
if (parts[1] && parts[1].length > 1) return
} else if (value.length > 2) {
return
}
if (parseFloat(value) > 80) return
setPercent(value)
setError('')
}
const handleSubmit = () => {
if (!percent || percent === '.') {
setError('숫자를 입력해주세요')
return
}
sendMessage(`${parseFloat(percent).toString()}%`, 'percent')
setError('')
}
return (
<InputWithUnit
value={percent}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder="할인율(%) 소수점 첫째 자리까지"
unit="%"
error={error}
inputRef={inputRef}
ariaLabel="할인율 입력"
/>
)
}
export function Price({ priceErrorMessage }: { priceErrorMessage?: string }) {
const [inputValue, setInputValue] = useState('')
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const sendMessage = useChatStore((s) => s.sendMessage)
const setPriceErrorMessage = useChatStore((s) => s.setPriceErrorMessage)
const displayError = priceErrorMessage || error
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (priceErrorMessage) setPriceErrorMessage('')
if (value === '') {
setInputValue('')
setError('')
return
}
const numberOnly = value.replace(/,/g, '')
if (!/^\d*$/.test(numberOnly)) return
if (parseInt(numberOnly) > 999999999999) return
setInputValue(numberOnly.replace(/\B(?=(\d{3})+(?!\d))/g, ','))
setError('')
}
const handleSubmit = () => {
if (!inputValue) {
if (priceErrorMessage) setPriceErrorMessage('')
setError('가격을 입력해주세요')
return
}
sendMessage(`${inputValue}`, 'price')
setError('')
}
return (
<InputWithUnit
value={inputValue}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder="가격(원)을 입력해주세요"
unit="원"
error={displayError}
inputRef={inputRef}
ariaLabel="가격 입력"
/>
)
}

View File

@ -0,0 +1,14 @@
import { useChatInit } from '@/features/chat/hooks/useChatInit'
import { ChatSection } from '@/features/chat/components/ChatSection'
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
// 콘텐츠 영역: 채팅 + 우측 메뉴. mock 데이터를 스토어에 적재한다.
export function ChatContainer() {
useChatInit()
return (
<div className="flex flex-1 min-h-0 w-full">
<ChatSection />
<MenuSection />
</div>
)
}

View File

@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { MOCK_CHAT_INIT } from '@/features/chat/mocks/mockChatInit'
import { MOCK_MESSAGES } from '@/features/chat/mocks/mockMessages'
// mock 세션/대화 데이터를 스토어에 적재 (추후 API 조회로 교체)
export function useChatInit() {
const setInitData = useChatInitStore((s) => s.setInitData)
const setMessages = useChatStore((s) => s.setMessages)
useEffect(() => {
setInitData(MOCK_CHAT_INIT)
setMessages(MOCK_MESSAGES)
}, [setInitData, setMessages])
}

View File

@ -0,0 +1,4 @@
// 공개 API (페이지에서 쓰는 것만)
export { ChatContainer } from '@/features/chat/containers/ChatContainer'
export { ItemSection } from '@/features/chat/components/ItemSection'
export { RemainingTime } from '@/features/chat/components/RemainingTime'

View File

@ -0,0 +1,45 @@
const digits = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구']
const units = ['', '십', '백', '천']
const higherUnits = ['', '만', '억', '조', '경', '해']
// 숫자를 한글 표기로 (예: 12000 → 일만이천)
export function numberToKorean(num: number): string {
if (num === 0) return '영'
const groups: number[] = []
let numStr = num.toString()
while (numStr.length > 0) {
groups.unshift(parseInt(numStr.slice(-4)))
numStr = numStr.slice(0, -4)
}
let result = ''
const groupLen = groups.length
for (let idx = 0; idx < groupLen; idx++) {
const group = groups[idx]
if (group === 0) continue
const groupStr = processGroup(group, idx === 0)
result += groupStr + higherUnits[groupLen - idx - 1]
}
return result
}
function processGroup(num: number, isFirstGroup = false): string {
const numStr = num.toString().padStart(4, '0')
let result = ''
for (let idx = 0; idx < numStr.length; idx++) {
const digit = parseInt(numStr[idx])
const unit = units[3 - idx]
if (digit === 0) continue
if (digit === 1) {
if (unit !== '') {
result += isFirstGroup && unit === '천' ? digits[digit] + unit : unit
} else {
result += '일'
}
} else {
result += digits[digit] + unit
}
}
return result
}

View File

@ -0,0 +1,24 @@
import type { ChatMessage } from '@/features/chat/types'
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
const reversed = [...messages].reverse()
const idx = reversed.findIndex((m) => m.bot_chat_type === type)
if (idx > 0 && reversed[idx - 1]?.sender === 'user') return reversed[idx - 1].script
return null
}
export function extractPart(script: string | null, prefix: string): string {
return script?.split(', ').find((p) => p.startsWith(prefix))?.replace(prefix, '') ?? ''
}
// 재협상(RSP) 폼 복원: 가격 + 사유(기타-내용 포함)
export function restoreRejectRSP(script: string | null) {
const empty = { price: '', selectedReason: '', reason: '' }
if (!script) return empty
const parts = script.split(', ')
const price = parts.find((p) => p.startsWith('공급희망가격-'))?.replace('공급희망가격-', '') ?? ''
const reasonRaw = parts.find((p) => p.startsWith('합의불가사유-'))?.replace('합의불가사유-', '') ?? ''
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
return { price, selectedReason: reasonRaw, reason: '' }
}

View File

@ -0,0 +1,15 @@
// 마감까지 남은 시간 'HH시간 MM분 SS초'. 지났으면 종료 문구, 잘못된 값은 '-'.
export function getTimeRemaining(targetDateTime: string | Date): string {
const now = Date.now()
const target = new Date(targetDateTime).getTime()
if (Number.isNaN(target)) return '-'
const diff = target - now
if (diff <= 0) return '종료되었습니다.'
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(hours)}시간 ${pad(minutes)}${pad(seconds)}`
}

View File

@ -0,0 +1,25 @@
import type { ChatMessage, UserButtonConfig } from '@/features/chat/types'
export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
export function deriveUserButtonConfig(
messages: ChatMessage[],
isLoading: boolean,
priceErrorMessage: string,
): UserButtonConfig {
if (isLoading) return { type: 'loading' }
if (!messages || messages.length === 0) return { type: '', text: '' }
const last = messages[messages.length - 1]
const mode = last.next_input_mode
const options = last.next_input_type
if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT }
if (mode === 'confirm') return { type: 'one-black', text: options?.[0] || '' }
if (mode === 'yes_no') return { type: 'black-white', textList: options || [] }
if (mode === 'percent') return { type: 'percent' }
if (mode === 'price') return { type: 'price', priceErrorMessage: priceErrorMessage || undefined }
if (mode === 'delivery_type') return { type: 'three-black', textList: options || [] }
return { type: '', text: '' }
}

View File

@ -0,0 +1,25 @@
import type { ChatInitData } from '@/features/chat/types'
// 마감까지 카운트다운이 보이도록 현재 시각 기준 미래로 설정
const END_TIME = new Date(Date.now() + 95 * 60 * 1000).toISOString()
// 임시 세션/상품 데이터 (API 연동 전)
export const MOCK_CHAT_INIT: ChatInitData = {
session_id: 's-001',
item_id: 'item-001',
quotation_id: 'qt-001',
item_name: '사무용 노트북 14인치',
item_code: 'IMK-10231',
item_image: '',
item_price: 1350000,
item_model_name: 'NB-1400-PRO',
item_maker_name: '삼성전자',
item_vat_yn: 'VAT별도',
item_delivery_fee_yn: 'N',
item_min_order_quantity: '10 EA',
item_lead_time: '7일',
item_spec: 'Intel Core i7 / 16GB RAM / 512GB SSD / 14인치 FHD',
quotation_memo:
'납기 엄수 부탁드립니다.\n세금계산서는 월말 일괄 발행합니다.\n상세 사양은 첨부 문서를 확인해주세요.',
quotation_end_time: END_TIME,
}

View File

@ -0,0 +1,106 @@
import type { ChatMessage, ChatSummary } from '@/features/chat/types'
// 전 메시지 템플릿을 한눈에 보기 위한 쇼케이스 목 대화 (실제 협상 흐름 아님)
const SUMMARY: ChatSummary = {
md_name: '김엠디',
item_moq: '10 EA',
md_email: 'md@example.com',
item_code: 'IMK-10231',
item_name: '사무용 노트북 14인치',
item_spec: 'Intel Core i7 / 16GB / 512GB SSD',
item_isVAT: false,
item_maker: '삼성전자',
item_model: 'NB-1400-PRO',
final_price: 1200000,
nego_end_date: '2026년 06월 17일 14시 30분',
supplier_name: '대한상사',
item_lead_time: '7일',
md_phone_number: '02-1234-5678',
nego_start_date: '2026년 06월 17일 14시 00분',
item_display_date: '2026년 06월 10일',
item_delivery_type: '협력사배송',
supplier_manager_name: '이담당',
supplier_manager_email: 'sales@example.com',
delivery_type: '협력사배송',
}
const base = {
bot_chat_type: null,
user_input_type: null,
script: null,
chat_end: false,
next_input_mode: null,
next_input_type: null,
summary: null,
indicator_value: null,
} as const
export const MOCK_MESSAGES: ChatMessage[] = [
{
...base,
chat_id: 'm1',
sender: 'bot',
script:
'안녕하세요, 협상을 시작하겠습니다. 본 협상은 자동으로 진행되며, 안내에 따라 응답해주시면 됩니다.',
step: '서비스안내',
display_step: '서비스안내',
},
{
...base,
chat_id: 'm2',
sender: 'bot',
bot_chat_type: 'indicator',
indicator_value: 62,
script: '현재까지의 협상 성공률은 아래와 같습니다.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm3',
sender: 'user',
user_input_type: 'price',
script: '1,200,000원',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm4',
sender: 'bot',
bot_chat_type: 'summaryCM',
summary: SUMMARY,
script: '제시해주신 금액으로 투찰 결과를 요약해드립니다.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm5',
sender: 'bot',
bot_chat_type: 'summaryRSP',
summary: SUMMARY,
script: '협상이 완료되었습니다. 최종 결과를 요약해드립니다.',
step: '협상종료',
display_step: '협상종료',
},
{
...base,
chat_id: 'm6',
sender: 'bot',
bot_chat_type: 'rejectCM',
script: '제시 금액이 수용되지 않았습니다. 최종 공급 희망 가격과 배송 형태를 입력해주세요.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm7',
sender: 'bot',
script: '추가로 제시할 가격이 있다면 입력해주세요.',
next_input_mode: 'price',
step: '가격협상',
display_step: '가격협상',
},
]

View File

@ -0,0 +1,32 @@
import { create } from 'zustand'
import type { ChatInitData } from '@/features/chat/types'
interface ChatInitStore extends ChatInitData {
setInitData: (data: Partial<ChatInitData>) => void
reset: () => void
}
const initialState: ChatInitData = {
session_id: '',
item_id: '',
quotation_id: '',
item_name: '',
item_code: '',
item_image: '',
item_price: 0,
item_model_name: '',
item_maker_name: '',
item_vat_yn: '',
item_delivery_fee_yn: '',
item_min_order_quantity: '',
item_lead_time: '',
item_spec: '',
quotation_memo: '',
quotation_end_time: '',
}
export const useChatInitStore = create<ChatInitStore>((set) => ({
...initialState,
setInitData: (data) => set(data),
reset: () => set(initialState),
}))

View File

@ -0,0 +1,63 @@
import { create } from 'zustand'
import type { ChatMessage, UserButtonConfig, UserInputType } from '@/features/chat/types'
import { deriveUserButtonConfig } from '@/features/chat/lib/userButtonConfig'
type ChatStore = {
messages: ChatMessage[]
userButtonConfig: UserButtonConfig
isLoading: boolean
priceErrorMessage: string
setMessages: (messages: ChatMessage[]) => void
setIsLoading: (isLoading: boolean) => void
setPriceErrorMessage: (message: string) => void
sendMessage: (text: string, inputType?: UserInputType) => void
}
let mockSeq = 0
export const useChatStore = create<ChatStore>((set, get) => ({
messages: [],
userButtonConfig: { type: '', text: '' },
isLoading: false,
priceErrorMessage: '',
setMessages: (messages) =>
set((s) => ({
messages,
userButtonConfig: deriveUserButtonConfig(messages, s.isLoading, s.priceErrorMessage),
})),
setIsLoading: (isLoading) =>
set((s) => ({
isLoading,
userButtonConfig: deriveUserButtonConfig(s.messages, isLoading, s.priceErrorMessage),
})),
setPriceErrorMessage: (priceErrorMessage) =>
set((s) => ({
priceErrorMessage,
userButtonConfig: deriveUserButtonConfig(s.messages, s.isLoading, priceErrorMessage),
})),
// mock: API 미연동이라 사용자 메시지를 로컬에 추가만 한다 (실제 협상 진행 로직 없음)
sendMessage: (text, inputType = 'text') => {
const { messages } = get()
const last = messages[messages.length - 1]
const userMsg: ChatMessage = {
chat_id: `mock-user-${mockSeq++}`,
sender: 'user',
bot_chat_type: null,
user_input_type: inputType,
script: text,
chat_end: false,
next_input_mode: null,
next_input_type: null,
step: last?.step ?? '',
display_step: last?.display_step ?? '',
summary: null,
indicator_value: null,
}
const next = [...messages, userMsg]
set({ messages: next, priceErrorMessage: '', userButtonConfig: deriveUserButtonConfig(next, false, '') })
},
}))

View File

@ -0,0 +1,82 @@
export type ChatSender = 'bot' | 'user'
export type BotChatType = 'indicator' | 'summaryRSP' | 'summaryCM' | 'rejectRSP' | 'rejectCM'
export type UserInputType = 'text' | 'percent' | 'price'
// 봇 마지막 메시지가 요구하는 다음 입력 형태 → UserButton 구성을 결정
export type NextInputMode = 'confirm' | 'yes_no' | 'percent' | 'price' | 'delivery_type'
export type ChatSummary = {
md_name: string
item_moq: string
md_email: string
item_code: string
item_name: string
item_spec: string
item_isVAT: boolean
item_maker: string
item_model: string
final_price: number
nego_end_date: string
supplier_name: string
item_lead_time: string
md_phone_number: string
nego_start_date: string
item_display_date: string
item_delivery_type: string
supplier_manager_name: string
supplier_manager_email: string
delivery_type: string | null
}
export type ChatMessage = {
chat_id: string
sender: ChatSender
bot_chat_type: BotChatType | null
user_input_type: UserInputType | null
script: string | null
chat_end: boolean
next_input_mode: NextInputMode | null
next_input_type: string[] | null
step: string
display_step: string
summary: ChatSummary | null
indicator_value: number | null
}
export type UserButtonType =
| 'one-black'
| 'one-gray'
| 'black-white'
| 'percent'
| 'three-black'
| 'price'
| 'loading'
| ''
export type UserButtonConfig = {
type: UserButtonType
text?: string
textList?: string[]
priceErrorMessage?: string
}
export type ChatInitData = {
session_id: string
item_id: string
quotation_id: string
item_name: string
item_code: string
item_image: string
item_price: number
item_model_name: string
item_maker_name: string
item_vat_yn: string
item_delivery_fee_yn: string
item_min_order_quantity: string
item_lead_time: string
item_spec: string
quotation_memo: string
quotation_end_time: string
}

View File

@ -2,6 +2,8 @@ import { useNavigate } from 'react-router'
import { List } from 'lucide-react'
import { cn, interactive } from '@/lib'
import { MainLayout, MainHeaderBar } from '@/layouts'
import { ChatContainer, ItemSection, RemainingTime } from '@/features/chat'
import { SidebarFooter } from '@/features/auth'
export function ChatPage() {
return (
@ -12,11 +14,11 @@ export function ChatPage() {
sidebar={<Sidebar />}
header={
<MainHeaderBar className="px-[140px]">
<span className="text-xl font-semibold"> : -</span>
<RemainingTime />
</MainHeaderBar>
}
>
{/* TODO: MainContent (ChatSection / MenuSection) */}
<ChatContainer />
</MainLayout>
)
}
@ -38,9 +40,8 @@ function ListNavButton() {
function Sidebar() {
return (
<>
{/* TODO: ItemSection */}
<div className="flex-1 overflow-y-auto" />
{/* TODO: FooterSection */}
<ItemSection />
<SidebarFooter />
</>
)
}