From 2386cb8867f02b90d89871abb7f0ea8ef9a2fac6 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 20 Jul 2026 17:27:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20negosium/front:=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=A7=88=EC=BC=93=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=ED=86=A0=EC=8A=A4=ED=92=8D=20UI=20?= =?UTF-8?q?=EB=A6=AC=EB=89=B4=EC=96=BC=20=E2=80=94=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=C2=B7=ED=8F=AC=ED=84=B8=20=EB=AA=A9=EB=A1=9D(KPI?= =?UTF-8?q?=C2=B7=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=B9=B4=EB=93=9C)=C2=B7?= =?UTF-8?q?=ED=98=91=EC=83=81=EB=B0=A9=203=EB=8B=A8(=EB=A7=90=ED=92=8D?= =?UTF-8?q?=EC=84=A0=C2=B7=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=C2=B7?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC/=EC=84=B1=EA=B3=B5=EB=A5=A0=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C)=C2=B7=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=EC=96=B4,=20=EB=AA=A9=EB=A1=9D=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=9C=A0=EC=A7=80=C2=B7?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B9=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Button.tsx | 8 +- frontend/src/components/ErrorPage.tsx | 12 +- frontend/src/components/Input.tsx | 4 +- .../features/auth/components/LoginForm.tsx | 2 +- .../auth/components/SidebarFooter.tsx | 23 ++- .../features/chat/components/ChatMessage.tsx | 72 ++++---- .../features/chat/components/ChatSection.tsx | 7 +- .../features/chat/components/ItemImage.tsx | 41 +++-- .../features/chat/components/ItemSection.tsx | 38 +++-- .../features/chat/components/MobileDrawer.tsx | 50 ++++++ .../chat/components/RemainingTime.tsx | 17 +- .../features/chat/components/UserButton.tsx | 48 ++---- .../features/chat/components/menu/Contact.tsx | 21 ++- .../features/chat/components/menu/Guide.tsx | 12 +- .../chat/components/menu/MDInformation.tsx | 50 +++--- .../chat/components/menu/MenuSection.tsx | 14 +- .../chat/components/menu/NegoStep.tsx | 96 +++++++---- .../chat/components/popup/GuideContent.tsx | 2 +- .../components/popup/ServiceGuidePopup.tsx | 8 +- .../components/popup/ServiceInfoPopup.tsx | 10 +- .../chat/components/templates/BidSummary.tsx | 61 +++---- .../chat/components/templates/Indicator.tsx | 93 +++-------- .../chat/components/templates/OtherReason.tsx | 37 +++-- .../chat/components/templates/RejectCM.tsx | 32 ++-- .../chat/components/templates/RejectRSP.tsx | 30 ++-- .../chat/components/templates/Summary.tsx | 138 ++++++++------- .../components/templates/rejectControls.tsx | 31 ++-- .../features/chat/components/userInputs.tsx | 10 +- .../features/chat/stores/useChatUiStore.ts | 16 ++ .../list/components/ActionSection.tsx | 49 ------ .../features/list/components/FilterGroup.tsx | 69 -------- .../src/features/list/components/KpiCards.tsx | 54 ++++++ .../features/list/components/Pagination.tsx | 103 ++++-------- .../features/list/components/RejectPopup.tsx | 144 ++++++++-------- .../features/list/components/StatusTabs.tsx | 40 +++++ .../features/list/components/TableSection.tsx | 79 --------- .../list/components/WorkspaceCards.tsx | 105 ++++++++++++ .../list/components/WorkspaceTable.tsx | 132 +++++++++++++++ .../list/components/paginationIcons.tsx | 32 ---- .../features/list/components/tableColumns.tsx | 45 ----- .../list/containers/ContentContainer.tsx | 106 ------------ .../list/containers/FilterContainer.tsx | 52 ------ .../list/containers/ListWorkspace.tsx | 157 ++++++++++++++++++ frontend/src/features/list/filterOptions.ts | 17 -- frontend/src/features/list/hooks/useList.ts | 36 +--- .../features/list/hooks/useSessionCounts.ts | 35 ++++ frontend/src/features/list/index.ts | 3 +- frontend/src/features/list/lib/datetime.ts | 6 - frontend/src/features/list/lib/status.ts | 19 +++ .../src/features/list/stores/useListStore.ts | 4 + frontend/src/index.css | 68 +++++--- frontend/src/layouts/MainHeaderBar.tsx | 2 +- frontend/src/layouts/MainLayout.tsx | 12 +- frontend/src/layouts/PortalHeader.tsx | 57 +++++++ frontend/src/lib/toast.ts | 11 +- frontend/src/pages/ChatPage.tsx | 95 +++++++++-- frontend/src/pages/ListPage.tsx | 31 +--- frontend/src/pages/LoginPage.tsx | 26 ++- 58 files changed, 1394 insertions(+), 1178 deletions(-) create mode 100644 frontend/src/features/chat/components/MobileDrawer.tsx create mode 100644 frontend/src/features/chat/stores/useChatUiStore.ts delete mode 100644 frontend/src/features/list/components/ActionSection.tsx delete mode 100644 frontend/src/features/list/components/FilterGroup.tsx create mode 100644 frontend/src/features/list/components/KpiCards.tsx create mode 100644 frontend/src/features/list/components/StatusTabs.tsx delete mode 100644 frontend/src/features/list/components/TableSection.tsx create mode 100644 frontend/src/features/list/components/WorkspaceCards.tsx create mode 100644 frontend/src/features/list/components/WorkspaceTable.tsx delete mode 100644 frontend/src/features/list/components/paginationIcons.tsx delete mode 100644 frontend/src/features/list/components/tableColumns.tsx delete mode 100644 frontend/src/features/list/containers/ContentContainer.tsx delete mode 100644 frontend/src/features/list/containers/FilterContainer.tsx create mode 100644 frontend/src/features/list/containers/ListWorkspace.tsx delete mode 100644 frontend/src/features/list/filterOptions.ts create mode 100644 frontend/src/features/list/hooks/useSessionCounts.ts delete mode 100644 frontend/src/features/list/lib/datetime.ts create mode 100644 frontend/src/features/list/lib/status.ts create mode 100644 frontend/src/layouts/PortalHeader.tsx 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 ( -
-
-