diff --git a/front/src/features/chat/components/ChatMessage.tsx b/front/src/features/chat/components/ChatMessage.tsx new file mode 100644 index 0000000..b4cea65 --- /dev/null +++ b/front/src/features/chat/components/ChatMessage.tsx @@ -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 ( +
+
+ +
+
+ ) +} + +function ChatList() { + const bottomRef = useRef(null) + const chats = useChatStore((s) => s.messages) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [chats]) + + if (!chats || chats.length === 0) { + return ( +
+

채팅 내역이 없습니다.

+
+ ) + } + + return ( +
+ {chats.map((message, index) => ( + + ))} +
+
+ ) +} + +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 ( + <> +
+
+ + ) + } + } + + return
{isBot ? : }
+}) + +const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) { + return ( +
+
+
{message.script || ''}
+
+ +
+ {message.bot_chat_type === 'indicator' && message.indicator_value != null && ( + + )} + {message.bot_chat_type === 'summaryRSP' && message.summary && } + {message.bot_chat_type === 'summaryCM' && message.summary && ( + + )} + {message.bot_chat_type === 'rejectRSP' && } + {message.bot_chat_type === 'rejectCM' && } +
+
+ ) +}) + +const UserMessage = memo(function UserMessage({ text }: { text: string }) { + return ( + <> +
+
+ {text} +
+
+
+ + ) +}) diff --git a/front/src/features/chat/components/ChatSection.tsx b/front/src/features/chat/components/ChatSection.tsx new file mode 100644 index 0000000..27d5e5a --- /dev/null +++ b/front/src/features/chat/components/ChatSection.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/front/src/features/chat/components/ItemImage.tsx b/front/src/features/chat/components/ItemImage.tsx new file mode 100644 index 0000000..ff88d06 --- /dev/null +++ b/front/src/features/chat/components/ItemImage.tsx @@ -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 ( +
+ + {isOpen && setIsOpen(false)} />} +
+ ) +} + +function Thumb({ src }: { src: string }) { + if (src) { + return 상품 이미지 + } + return ( +
+ +
+ ) +} + +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( +
+
e.stopPropagation()}> + {src ? ( + 상품 이미지 + ) : ( +
+ +
+ )} + +
+
, + document.body, + ) +} diff --git a/front/src/features/chat/components/ItemSection.tsx b/front/src/features/chat/components/ItemSection.tsx new file mode 100644 index 0000000..d9744c8 --- /dev/null +++ b/front/src/features/chat/components/ItemSection.tsx @@ -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 ( +
+ + +
+ ) +} + +interface TooltipState { + text: string + x: number + y: number + above?: boolean +} + +function Tooltip({ text, x, y, above }: TooltipState) { + return createPortal( +
+
+ {text} +
+
, + 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(null) + const hideTimer = useRef | 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 ( +
+
{title}
+
displayData !== '-' && showTooltip(e, displayData)} + onMouseLeave={hideTooltip} + > + {displayData} +
+
+ ) + } + + return ( +
+
item_name && showTooltip(e, item_name)} + onMouseLeave={hideTooltip} + > + {item_name} +
+ +
+
+ {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)} + +
+
규격
+
item_spec && showTooltip(e, item_spec, true)} + onMouseLeave={hideTooltip} + > + {item_spec || '-'} +
+
+
+
+ + {tooltip && } +
+ ) +} diff --git a/front/src/features/chat/components/RemainingTime.tsx b/front/src/features/chat/components/RemainingTime.tsx new file mode 100644 index 0000000..df6edfe --- /dev/null +++ b/front/src/features/chat/components/RemainingTime.tsx @@ -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 협상 잔여 시간 : {remaining} +} diff --git a/front/src/features/chat/components/UserButton.tsx b/front/src/features/chat/components/UserButton.tsx new file mode 100644 index 0000000..064f75a --- /dev/null +++ b/front/src/features/chat/components/UserButton.tsx @@ -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 ( +
+
+
+
+ {type === 'one-black' && } + {type === 'one-gray' && } + {type === 'black-white' && } + {type === 'percent' && } + {type === 'three-black' && ( + + )} + {type === 'price' && } + {type === 'loading' && } +
+
+ +
+
+
+ ) +} + +function GoToList() { + const navigate = useNavigate() + return ( + + ) +} + +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 ( + + ) +} + +function OneGray({ text }: { text: string }) { + const sendMessage = useChatStore((s) => s.sendMessage) + return ( + + ) +} + +function ThreeBlack({ textList }: { textList: [string, string, string] }) { + const sendMessage = useChatStore((s) => s.sendMessage) + return ( +
+ {textList.map((t) => ( + + ))} +
+ ) +} + +function BlackWhite({ textList }: { textList: [string, string] }) { + const sendMessage = useChatStore((s) => s.sendMessage) + return ( +
+ + +
+ ) +} diff --git a/front/src/features/chat/components/menu/Contact.tsx b/front/src/features/chat/components/menu/Contact.tsx new file mode 100644 index 0000000..7530870 --- /dev/null +++ b/front/src/features/chat/components/menu/Contact.tsx @@ -0,0 +1,22 @@ +import { interactive, cn } from '@/lib' + +// 헬프데스크 (TODO: 이용가이드 팝업 연동, 연락처는 추후 설정값으로 교체) +export function Contact() { + return ( +
+
+
헬프 데스크
+ +
+
-
+
-
+
+
+
+ ) +} diff --git a/front/src/features/chat/components/menu/Guide.tsx b/front/src/features/chat/components/menu/Guide.tsx new file mode 100644 index 0000000..7a0abcf --- /dev/null +++ b/front/src/features/chat/components/menu/Guide.tsx @@ -0,0 +1,18 @@ +import { ChevronRight } from 'lucide-react' +import { interactive, cn } from '@/lib' + +// 유의사항 및 이용방법 (TODO: 이용가이드 팝업 연동) +export function Guide() { + return ( + + ) +} diff --git a/front/src/features/chat/components/menu/MDInformation.tsx b/front/src/features/chat/components/menu/MDInformation.tsx new file mode 100644 index 0000000..61a7f30 --- /dev/null +++ b/front/src/features/chat/components/menu/MDInformation.tsx @@ -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 ( +
+
+
+
+
MD 안내사항
+ +
+
+ +
+
+ {memoArray.map((item, i) => ( +
+ • +
{linkText(item)}
+
+ ))} +
+
+
+
+ ) +} + +function linkText(text: string) { + const urlRegex = /(https?:\/\/[^\s]+)/g + const isUrl = (s: string) => /^https?:\/\/[^\s]+$/.test(s) + const parts = text.split(urlRegex) + return ( +
+ {parts.map((part, i) => + isUrl(part) ? ( + + 상품 사이트로 이동 + + ) : ( + {i > 0 && isUrl(parts[i - 1]) ? part.replace(/^(\s)/, '') : part} + ), + )} +
+ ) +} diff --git a/front/src/features/chat/components/menu/MenuSection.tsx b/front/src/features/chat/components/menu/MenuSection.tsx new file mode 100644 index 0000000..75c5036 --- /dev/null +++ b/front/src/features/chat/components/menu/MenuSection.tsx @@ -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 ( +
+
+ + + +
+ +
+ ) +} diff --git a/front/src/features/chat/components/menu/NegoStep.tsx b/front/src/features/chat/components/menu/NegoStep.tsx new file mode 100644 index 0000000..f9f8cce --- /dev/null +++ b/front/src/features/chat/components/menu/NegoStep.tsx @@ -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 ( +
+
+
+
+
협상절차
+ +
+
+ +
+
+ {STEPS.map((step, i) => ( +
+
+ {i + 1}. {step} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/front/src/features/chat/components/templates/BidSummary.tsx b/front/src/features/chat/components/templates/BidSummary.tsx new file mode 100644 index 0000000..66b614d --- /dev/null +++ b/front/src/features/chat/components/templates/BidSummary.tsx @@ -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 ( +
+

투찰 결과 요약

+ + + + +
+ ) +} + +function BidItem({ title, value }: { title: string; value: string }) { + return ( +
+ +
+ {title} : +
 {value}
+
+
+ ) +} + +function BidPrice({ number, isVAT, title }: { number: number; isVAT: boolean; title: string }) { + const numberString = number.toLocaleString() + return ( +
+ +
+
{title} :
+
+  {numberString}원 + +  ({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원) + +  {isVAT ? 'VAT포함' : 'VAT별도'} +
+
+
+ ) +} + +function Dot() { + return ( +
+

•

+
+ ) +} diff --git a/front/src/features/chat/components/templates/Indicator.tsx b/front/src/features/chat/components/templates/Indicator.tsx new file mode 100644 index 0000000..b62653e --- /dev/null +++ b/front/src/features/chat/components/templates/Indicator.tsx @@ -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 ( +
+
+ {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 ( +
+ {isTarget && ( +
+
+
+ + {percent}% + +
+
+ )} +
+ ) + })} +
+
+ ) +} + +export function Indicator({ number }: { number: number }) { + const percent = Math.min(Math.max(number, 0), 100) + + return ( +
+

협상 성공률

+
+ {percent === 0 ? ( +
+
▼ 0%
+
+ ) : ( +
+ )} +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/front/src/features/chat/components/templates/OtherReason.tsx b/front/src/features/chat/components/templates/OtherReason.tsx new file mode 100644 index 0000000..a83152b --- /dev/null +++ b/front/src/features/chat/components/templates/OtherReason.tsx @@ -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(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 ( +
+
+ +