diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 64464a7..39d1fa7 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -10,18 +10,18 @@ export type ButtonVariant = export type ButtonSize = 'sm' | 'md' | 'lg' const base = - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium select-none ' + + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl font-semibold select-none ' + interactive + ' focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' + 'disabled:pointer-events-none disabled:opacity-50' const variantClass: Record = { - primary: 'bg-primary text-primary-foreground', + primary: 'bg-primary text-primary-foreground shadow-sm', secondary: 'bg-secondary text-secondary-foreground', - outline: 'border border-input bg-background', + outline: 'border border-border bg-background text-foreground', // 투명 배경이라 brightness 무효 → bg 하이라이트 ghost: 'hover:bg-accent hover:text-accent-foreground', - destructive: 'bg-destructive text-white', + destructive: 'bg-destructive text-white shadow-sm', } const sizeClass: Record = { diff --git a/frontend/src/components/ErrorPage.tsx b/frontend/src/components/ErrorPage.tsx index 42eccc5..5b36181 100644 --- a/frontend/src/components/ErrorPage.tsx +++ b/frontend/src/components/ErrorPage.tsx @@ -18,13 +18,13 @@ export function ErrorPage({ }: ErrorPageProps) { return (
-
-
- +
+
+
-
-

{message}

-

{description}

+
+

{message}

+

{description}

{onRetry && ( +
) } diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index 7f1ebf1..a8d5c8d 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, memo } from 'react' -import { cn } from '@/lib' +import { useMeQuery } from '@/apis' import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import type { ChatMessage as ChatMessageType } from '@/features/chat/types' @@ -10,11 +10,13 @@ import { BidSummary } from '@/features/chat/components/templates/BidSummary' import { RejectRSP } from '@/features/chat/components/templates/RejectRSP' import { RejectCM } from '@/features/chat/components/templates/RejectCM' +const AI_LABEL = '아이마켓코리아 (구매 MD)' + export function ChatMessage() { return ( -
+
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */} -
+
@@ -34,7 +36,7 @@ function ChatList() { if (!chats || chats.length === 0) { return (
-

채팅 내역이 없습니다.

+

채팅 내역이 없습니다.

) } @@ -42,7 +44,7 @@ function ChatList() { return (
{chats.map((message, index) => ( - + ))} {isLoading && }
@@ -50,14 +52,15 @@ function ChatList() { ) } -// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터. +// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터(AI 말풍선 톤). function TypingBubble() { return ( -
-
- - - +
+ {AI_LABEL} +
+ + +
) @@ -65,46 +68,43 @@ function TypingBubble() { const MessageItem = memo(function MessageItem({ message, - isFirst, messages, currentIndex, }: { message: ChatMessageType - isFirst: boolean messages: ChatMessageType[] currentIndex: number }) { const isBot = message.sender === 'bot' - // 직전이 reject 폼이면 사용자 답변은 숨기고 구분선만 표시 + // 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음) if (!isBot && currentIndex > 0) { const prev = messages[currentIndex - 1] if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') { - return ( - <> -
-
- - ) + return null } } - return
{isBot ? : }
+ return isBot ? : }) -const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) { +const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageType }) { // 인디케이터는 이번 방문에서 '협상중'이었던 세션에서만 표시. - // 진행 화면에서 실시간으로 완료되면 sticky 플래그로 계속 노출하고, - // 완료/거부 세션에 결과 열람 목적으로 재진입한 경우엔 값이 와도 숨긴다. const showIndicator = useChatInitStore((s) => s.wasInProgress) + const hasScript = Boolean(message.script && message.script.trim()) + return ( -
-
- {/* 봇 스크립트의 `**굵게**` 경량 마크업 해석 (표현 규칙은 프론트 소유 — emphasis.tsx) */} -
{renderEmphasis(message.script || '')}
+
+
+ {AI_LABEL} + {hasScript && ( +
+ {renderEmphasis(message.script || '')} +
+ )}
-
+
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && ( )} @@ -126,14 +126,14 @@ const BotMessage = memo(function BotMessage({ message, isFirst }: { message: Cha }) const UserMessage = memo(function UserMessage({ text }: { text: string }) { + const { data: user } = useMeQuery() + const label = `${user?.supplierName ?? '공급사'} (나)` return ( - <> -
-
- {text} -
+
+ {label} +
+ {text}
-
- +
) }) diff --git a/frontend/src/features/chat/components/ChatSection.tsx b/frontend/src/features/chat/components/ChatSection.tsx index 27d5e5a..bd47e77 100644 --- a/frontend/src/features/chat/components/ChatSection.tsx +++ b/frontend/src/features/chat/components/ChatSection.tsx @@ -5,9 +5,12 @@ import { UserButton } from '@/features/chat/components/UserButton' export function ChatSection() { const { userButtonConfig } = useChatStore() return ( -
+
- + {/* 하단 액션 덱 */} +
+ +
) } diff --git a/frontend/src/features/chat/components/ItemImage.tsx b/frontend/src/features/chat/components/ItemImage.tsx index 2d7adb0..972626e 100644 --- a/frontend/src/features/chat/components/ItemImage.tsx +++ b/frontend/src/features/chat/components/ItemImage.tsx @@ -8,34 +8,33 @@ export function ItemImage() { 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() @@ -53,9 +52,9 @@ function ImageModal({ src, onClose }: { src: string; onClose: () => void }) { >
e.stopPropagation()}> {src ? ( - 상품 이미지 + 상품 이미지 ) : ( -
+
)} @@ -63,9 +62,9 @@ function ImageModal({ src, onClose }: { src: string; onClose: () => void }) { 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" + className="absolute right-3 top-3 flex size-9 items-center justify-center rounded-full border border-border bg-white text-neutral-80 transition-colors hover:bg-neutral-10" > - +
, diff --git a/frontend/src/features/chat/components/ItemSection.tsx b/frontend/src/features/chat/components/ItemSection.tsx index fc65350..5992be4 100644 --- a/frontend/src/features/chat/components/ItemSection.tsx +++ b/frontend/src/features/chat/components/ItemSection.tsx @@ -1,6 +1,5 @@ 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' import { formatLeadTime } from '@/features/chat/lib/format' @@ -74,19 +73,18 @@ function ItemInfo() { 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 renderRow = (title: string, data: string) => { const displayData = data || '-' return ( -
-
{title}
-
+ {title} + displayData !== '-' && showTooltip(e, displayData)} onMouseLeave={hideTooltip} > {displayData} -
+
) } @@ -94,31 +92,35 @@ function ItemInfo() { 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_code)} + {renderRow('단가', formattedPrice)} + {renderRow('모델명', item_model_name)} {renderRow('제조사', item_maker_name)} {renderRow('최소주문수량', item_min_order_quantity)} {renderRow('리드타임', formatLeadTime(item_lead_time))} -
-
규격
-
+ 규격 + item_spec && showTooltip(e, item_spec, true)} onMouseLeave={hideTooltip} > {item_spec || '-'} -
+
diff --git a/frontend/src/features/chat/components/MobileDrawer.tsx b/frontend/src/features/chat/components/MobileDrawer.tsx new file mode 100644 index 0000000..8707892 --- /dev/null +++ b/frontend/src/features/chat/components/MobileDrawer.tsx @@ -0,0 +1,50 @@ +import { type ReactNode, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { X } from 'lucide-react' +import { cn } from '@/lib' + +interface MobileDrawerProps { + open: boolean + onClose: () => void + side?: 'left' | 'right' + title: string + children: ReactNode +} + +// 모바일 전용 슬라이드 드로어(lg 미만). 데스크톱에선 절대 노출되지 않도록 lg:hidden 을 강제한다. +export function MobileDrawer({ open, onClose, side = 'left', title, children }: MobileDrawerProps) { + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose() + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, onClose]) + + if (!open) return null + + return createPortal( +
+
+
+
+ {title} + +
+
{children}
+
+
, + document.body, + ) +} diff --git a/frontend/src/features/chat/components/RemainingTime.tsx b/frontend/src/features/chat/components/RemainingTime.tsx index df6edfe..c631117 100644 --- a/frontend/src/features/chat/components/RemainingTime.tsx +++ b/frontend/src/features/chat/components/RemainingTime.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react' +import { Clock } from 'lucide-react' +import { cn } from '@/lib' import { getTimeRemaining } from '@/features/chat/lib/remainingTime' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' -// 헤더의 협상 잔여 시간. 1초마다 tick 을 올려 렌더 중 남은 시간을 다시 계산한다. +// 헤더의 협상 잔여 시간 칩. 1초마다 tick 을 올려 렌더 중 남은 시간을 다시 계산한다. export function RemainingTime() { const { quotation_end_time } = useChatInitStore() const [, setTick] = useState(0) @@ -13,6 +15,17 @@ export function RemainingTime() { }, []) const remaining = quotation_end_time ? getTimeRemaining(quotation_end_time) : '-' + const ended = remaining === '종료되었습니다.' || remaining === '-' - return 협상 잔여 시간 : {remaining} + return ( + + + 협상 마감 {remaining} + + ) } diff --git a/frontend/src/features/chat/components/UserButton.tsx b/frontend/src/features/chat/components/UserButton.tsx index c2fc1d2..333eafa 100644 --- a/frontend/src/features/chat/components/UserButton.tsx +++ b/frontend/src/features/chat/components/UserButton.tsx @@ -1,5 +1,4 @@ import { useNavigate } from 'react-router' -import { List } from 'lucide-react' import { cn } from '@/lib' import { useChatStore } from '@/features/chat/stores/useChatStore' import { Percent, Price } from '@/features/chat/components/userInputs' @@ -8,37 +7,28 @@ import type { UserButtonConfig } from '@/features/chat/types' // max-[1180px]: 채팅 폭이 좁아지면 버튼 패딩·최소폭 축소, 상품목록은 아이콘만 남긴다 const style = { - goToList: - 'flex w-[159px] max-[1180px]:w-[48px] h-[48px] bg-neutral-40 hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-neutral-80 cursor-pointer gap-2 max-[1180px]:gap-0 transition-all ease-out hover:scale-[1.01]', black: - 'flex min-w-[120px] px-[32px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] 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] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] 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]', + 'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-brand-600 hover:bg-brand-700 rounded-xl items-center justify-center text-sm font-bold text-white shadow-sm cursor-pointer whitespace-nowrap transition-all duration-200 ease-out active:scale-[0.98]', + gray: 'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-neutral-60 hover:brightness-[0.97] rounded-xl items-center justify-center text-sm font-bold text-white cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]', white: - 'flex min-w-[120px] px-[32px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] 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]', + 'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]', } export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) { if (type === '') return null return ( - // pr-[16px]: 채팅 스크롤 영역(ChatMessage 바깥 pr-[16px])과 오른쪽 끝 정렬 -
-
-
-
- {type === 'one-black' && } - {type === 'one-gray' && } - {type === 'black-white' && } - {type === 'percent' && } - {type === 'three-black' && ( - - )} - {type === 'price' && } - {type === 'loading' && } -
-
- -
+
+
+ {type === 'one-black' && } + {type === 'one-gray' && } + {type === 'black-white' && } + {type === 'percent' && } + {type === 'three-black' && ( + + )} + {type === 'price' && } + {type === 'loading' && }
) @@ -57,16 +47,6 @@ function LoadingDots() { ) } -function GoToList() { - const navigate = useNavigate() - return ( - - ) -} - function OneBlack({ text }: { text: string }) { const navigate = useNavigate() const sendMessage = useChatStore((s) => s.sendMessage) diff --git a/frontend/src/features/chat/components/menu/Contact.tsx b/frontend/src/features/chat/components/menu/Contact.tsx index 927b00f..4a56ba1 100644 --- a/frontend/src/features/chat/components/menu/Contact.tsx +++ b/frontend/src/features/chat/components/menu/Contact.tsx @@ -7,22 +7,27 @@ export function Contact() { const [isOpen, setIsOpen] = useState(false) return ( -
-
-
헬프 데스크
+
+
+ 헬프데스크 +
+
-
-
010-0000-0000
-
o2odev@o2o.kr
+
+ 010-0000-0000 + o2odev@o2o.kr
{isOpen && setIsOpen(false)} />} -
+
) } diff --git a/frontend/src/features/chat/components/menu/Guide.tsx b/frontend/src/features/chat/components/menu/Guide.tsx index 3f79780..cc1b8a4 100644 --- a/frontend/src/features/chat/components/menu/Guide.tsx +++ b/frontend/src/features/chat/components/menu/Guide.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { ChevronRight } from 'lucide-react' -import { interactive, cn } from '@/lib' import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup' // 유의사항 및 이용방법 — 클릭 시 안내 팝업을 연다. @@ -12,13 +11,12 @@ export function Guide() { {isOpen && setIsOpen(false)} />} diff --git a/frontend/src/features/chat/components/menu/MDInformation.tsx b/frontend/src/features/chat/components/menu/MDInformation.tsx index 61a7f30..14dfa84 100644 --- a/frontend/src/features/chat/components/menu/MDInformation.tsx +++ b/frontend/src/features/chat/components/menu/MDInformation.tsx @@ -22,39 +22,33 @@ export function MDInformation({ isOpen, setIsOpen }: Props) { if (memoArray.length === 0) return null return ( -
-
-
-
-
MD 안내사항
- -
-
+
+ -
-
+ {isOpen && ( +
+
{memoArray.map((item, i) => ( -
- +
+
{linkText(item)}
))}
-
-
+ )} +
) } @@ -63,7 +57,7 @@ function linkText(text: string) { const isUrl = (s: string) => /^https?:\/\/[^\s]+$/.test(s) const parts = text.split(urlRegex) return ( -
+
{parts.map((part, i) => isUrl(part) ? ( 상품 사이트로 이동 diff --git a/frontend/src/features/chat/components/menu/MenuSection.tsx b/frontend/src/features/chat/components/menu/MenuSection.tsx index c581635..1e9700e 100644 --- a/frontend/src/features/chat/components/menu/MenuSection.tsx +++ b/frontend/src/features/chat/components/menu/MenuSection.tsx @@ -1,16 +1,24 @@ import { useState } from 'react' +import { cn } from '@/lib' 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() { +// rail = 데스크톱 우측 고정 패널(lg+), drawer = 모바일 드로어 내부. +// 보고서식: 박스 카드가 아니라 하나의 흰 패널 안에 납작한 섹션들을 쌓는다. +export function MenuSection({ variant = 'rail' }: { variant?: 'rail' | 'drawer' }) { const [isMDOpen, setIsMDOpen] = useState(true) const [isStepOpen, setIsStepOpen] = useState(true) + const wrap = + variant === 'rail' + ? 'hidden lg:flex flex-col w-[300px] max-[1350px]:w-[280px] max-[1180px]:w-[260px] h-full overflow-y-auto bg-white border-l border-border px-5 pt-6 pb-4' + : 'flex flex-col w-full p-5' + return ( -
-
+
+
diff --git a/frontend/src/features/chat/components/menu/NegoStep.tsx b/frontend/src/features/chat/components/menu/NegoStep.tsx index f9f8cce..c813955 100644 --- a/frontend/src/features/chat/components/menu/NegoStep.tsx +++ b/frontend/src/features/chat/components/menu/NegoStep.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from 'lucide-react' +import { Check, ChevronDown } from 'lucide-react' import { cn } from '@/lib' import { useChatStore } from '@/features/chat/stores/useChatStore' @@ -7,46 +7,78 @@ interface Props { setIsOpen: (isOpen: boolean) => void } -const STEPS = ['서비스안내', '담당자확인', '협상품목안내', '가격협상', '협상종료'] +const STEPS = [ + { name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' }, + { name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' }, + { name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' }, + { name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' }, + { name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' }, +] export function NegoStep({ isOpen, setIsOpen }: Props) { const chats = useChatStore((s) => s.messages) const currentStep = chats[chats.length - 1]?.display_step || '서비스안내' + const currentIndex = Math.max( + 0, + STEPS.findIndex((s) => s.name === currentStep), + ) return ( -
-
-
-
-
협상절차
- -
-
+
+ -
-
- {STEPS.map((step, i) => ( -
-
- {i + 1}. {step} + {isOpen && ( +
+
+ {/* 세로 레일 */} + + {STEPS.map((step, i) => { + const done = i < currentIndex + const current = i === currentIndex + return ( +
+
+ {done ? : i + 1} +
+
+

+ {step.name} +

+

+ {step.desc} +

+
-
- ))} + ) + })}
-
-
+ )} +
) } diff --git a/frontend/src/features/chat/components/popup/GuideContent.tsx b/frontend/src/features/chat/components/popup/GuideContent.tsx index 99837af..79ad893 100644 --- a/frontend/src/features/chat/components/popup/GuideContent.tsx +++ b/frontend/src/features/chat/components/popup/GuideContent.tsx @@ -23,7 +23,7 @@ export function GuideContent() { return (
-
+
협상 유의 사항 및 서비스 이용 방법 안내
diff --git a/frontend/src/features/chat/components/popup/ServiceGuidePopup.tsx b/frontend/src/features/chat/components/popup/ServiceGuidePopup.tsx index 94d26c5..583bf94 100644 --- a/frontend/src/features/chat/components/popup/ServiceGuidePopup.tsx +++ b/frontend/src/features/chat/components/popup/ServiceGuidePopup.tsx @@ -7,19 +7,19 @@ import { GuideContent, GuideContactBox } from './GuideContent' export function ServiceGuidePopup({ onClose }: { onClose: () => void }) { return ( -
+
-
+
diff --git a/frontend/src/features/chat/components/popup/ServiceInfoPopup.tsx b/frontend/src/features/chat/components/popup/ServiceInfoPopup.tsx index 99e655b..16a40af 100644 --- a/frontend/src/features/chat/components/popup/ServiceInfoPopup.tsx +++ b/frontend/src/features/chat/components/popup/ServiceInfoPopup.tsx @@ -10,8 +10,8 @@ interface ServiceInfoPopupProps { } const buttonStyle = cn( - 'flex h-[50px] w-[168px] items-center justify-center rounded-full', - 'border border-neutral-30 bg-white title-5 text-neutral-70', + 'flex h-[46px] flex-1 sm:flex-none sm:w-[168px] items-center justify-center rounded-xl', + 'border border-border bg-white text-sm font-bold text-neutral-70 hover:bg-neutral-10', interactive, ) @@ -20,13 +20,13 @@ export function ServiceInfoPopup({ isOpen, onNeverShowAgain, onCloseToday }: Ser if (!isOpen) return null return ( -
-
+
+
-
+
diff --git a/frontend/src/features/chat/components/templates/BidSummary.tsx b/frontend/src/features/chat/components/templates/BidSummary.tsx index 66b614d..5d2c9cf 100644 --- a/frontend/src/features/chat/components/templates/BidSummary.tsx +++ b/frontend/src/features/chat/components/templates/BidSummary.tsx @@ -1,3 +1,4 @@ +import { CheckCircle2 } from 'lucide-react' import { numberToKorean } from '@/features/chat/lib/koreanNumber' interface BidSummaryProps { @@ -8,54 +9,42 @@ interface BidSummaryProps { isVAT: boolean } -// 투찰 결과 요약 카드 +// 투찰 결과 요약 카드 (보고서식 final-summary) export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }: BidSummaryProps) { + const priceStr = (bidPrice || 0).toLocaleString() 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, '')))}원) +
+ + +
+ 투찰 가격 + + {priceStr}원 + ({numberToKorean(bidPrice || 0)}원) + {isVAT ? 'VAT포함' : 'VAT별도'} -  {isVAT ? 'VAT포함' : 'VAT별도'}
+
) } -function Dot() { +function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return ( -
-

+
+ {label} + + {value} +
) } diff --git a/frontend/src/features/chat/components/templates/Indicator.tsx b/frontend/src/features/chat/components/templates/Indicator.tsx index b62653e..cc60456 100644 --- a/frontend/src/features/chat/components/templates/Indicator.tsx +++ b/frontend/src/features/chat/components/templates/Indicator.tsx @@ -1,78 +1,31 @@ -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}% - -
-
- )} -
- ) - })} -
-
- ) -} - +// 협상 성공률 카드 (0~100%). 10칸 컬러 블록바 — 구간별 색(빨강→주황→초록)으로 채운다. export function Indicator({ number }: { number: number }) { const percent = Math.min(Math.max(number, 0), 100) + const filled = Math.round(percent / 10) // 0~10칸 + const color = percent < 40 ? '#F04452' : percent < 70 ? '#F5A623' : '#0B8F57' return ( -
-

협상 성공률

-
- {percent === 0 ? ( -
-
▼ 0%
-
- ) : ( -
- )} -
- {Array.from({ length: 10 }).map((_, i) => ( - - ))} -
+
+
+

협상 성공률

+ + {percent}% +
+ +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ +

+ 협상이 진행될수록 성공률이 실시간으로 갱신됩니다. +

) } diff --git a/frontend/src/features/chat/components/templates/OtherReason.tsx b/frontend/src/features/chat/components/templates/OtherReason.tsx index a83152b..eb8bf3c 100644 --- a/frontend/src/features/chat/components/templates/OtherReason.tsx +++ b/frontend/src/features/chat/components/templates/OtherReason.tsx @@ -39,19 +39,20 @@ export function OtherReason({ const isTextareaDisabled = disabled || !isChecked return ( -
-
-