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:
parent
54b44c0877
commit
147f9dd007
115
front/src/features/chat/components/ChatMessage.tsx
Normal file
115
front/src/features/chat/components/ChatMessage.tsx
Normal 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]" />
|
||||
</>
|
||||
)
|
||||
})
|
||||
13
front/src/features/chat/components/ChatSection.tsx
Normal file
13
front/src/features/chat/components/ChatSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
front/src/features/chat/components/ItemImage.tsx
Normal file
74
front/src/features/chat/components/ItemImage.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
128
front/src/features/chat/components/ItemSection.tsx
Normal file
128
front/src/features/chat/components/ItemSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
front/src/features/chat/components/RemainingTime.tsx
Normal file
18
front/src/features/chat/components/RemainingTime.tsx
Normal 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>
|
||||
}
|
||||
99
front/src/features/chat/components/UserButton.tsx
Normal file
99
front/src/features/chat/components/UserButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
front/src/features/chat/components/menu/Contact.tsx
Normal file
22
front/src/features/chat/components/menu/Contact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
front/src/features/chat/components/menu/Guide.tsx
Normal file
18
front/src/features/chat/components/menu/Guide.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
front/src/features/chat/components/menu/MDInformation.tsx
Normal file
84
front/src/features/chat/components/menu/MDInformation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
front/src/features/chat/components/menu/MenuSection.tsx
Normal file
21
front/src/features/chat/components/menu/MenuSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
front/src/features/chat/components/menu/NegoStep.tsx
Normal file
52
front/src/features/chat/components/menu/NegoStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
front/src/features/chat/components/templates/BidSummary.tsx
Normal file
61
front/src/features/chat/components/templates/BidSummary.tsx
Normal 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"> {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"> {numberString}원</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep">
|
||||
({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원)
|
||||
</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep"> {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>
|
||||
)
|
||||
}
|
||||
78
front/src/features/chat/components/templates/Indicator.tsx
Normal file
78
front/src/features/chat/components/templates/Indicator.tsx
Normal 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">▼ 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>
|
||||
)
|
||||
}
|
||||
84
front/src/features/chat/components/templates/OtherReason.tsx
Normal file
84
front/src/features/chat/components/templates/OtherReason.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
front/src/features/chat/components/templates/RejectCM.tsx
Normal file
115
front/src/features/chat/components/templates/RejectCM.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
front/src/features/chat/components/templates/RejectRSP.tsx
Normal file
155
front/src/features/chat/components/templates/RejectRSP.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
front/src/features/chat/components/templates/Summary.tsx
Normal file
91
front/src/features/chat/components/templates/Summary.tsx
Normal 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} : </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"> {numberString}원</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep">
|
||||
({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원)
|
||||
</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep"> {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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
157
front/src/features/chat/components/userInputs.tsx
Normal file
157
front/src/features/chat/components/userInputs.tsx
Normal 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="가격 입력"
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
front/src/features/chat/containers/ChatContainer.tsx
Normal file
14
front/src/features/chat/containers/ChatContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
front/src/features/chat/hooks/useChatInit.ts
Normal file
16
front/src/features/chat/hooks/useChatInit.ts
Normal 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])
|
||||
}
|
||||
4
front/src/features/chat/index.ts
Normal file
4
front/src/features/chat/index.ts
Normal 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'
|
||||
45
front/src/features/chat/lib/koreanNumber.ts
Normal file
45
front/src/features/chat/lib/koreanNumber.ts
Normal 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
|
||||
}
|
||||
24
front/src/features/chat/lib/rejectForm.ts
Normal file
24
front/src/features/chat/lib/rejectForm.ts
Normal 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: '' }
|
||||
}
|
||||
15
front/src/features/chat/lib/remainingTime.ts
Normal file
15
front/src/features/chat/lib/remainingTime.ts
Normal 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)}초`
|
||||
}
|
||||
25
front/src/features/chat/lib/userButtonConfig.ts
Normal file
25
front/src/features/chat/lib/userButtonConfig.ts
Normal 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: '' }
|
||||
}
|
||||
25
front/src/features/chat/mocks/mockChatInit.ts
Normal file
25
front/src/features/chat/mocks/mockChatInit.ts
Normal 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,
|
||||
}
|
||||
106
front/src/features/chat/mocks/mockMessages.ts
Normal file
106
front/src/features/chat/mocks/mockMessages.ts
Normal 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: '가격협상',
|
||||
},
|
||||
]
|
||||
32
front/src/features/chat/stores/useChatInitStore.ts
Normal file
32
front/src/features/chat/stores/useChatInitStore.ts
Normal 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),
|
||||
}))
|
||||
63
front/src/features/chat/stores/useChatStore.ts
Normal file
63
front/src/features/chat/stores/useChatStore.ts
Normal 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, '') })
|
||||
},
|
||||
}))
|
||||
82
front/src/features/chat/types.ts
Normal file
82
front/src/features/chat/types.ts
Normal 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
|
||||
}
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user