Merge branch 'design/frontend' into feature/landing

This commit is contained in:
Mina Choi 2026-07-21 08:48:07 +09:00
commit e93820a51b
58 changed files with 1394 additions and 1178 deletions

View File

@ -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<ButtonVariant, string> = {
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<ButtonSize, string> = {

View File

@ -18,13 +18,13 @@ export function ErrorPage({
}: ErrorPageProps) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-6 p-12">
<div className="flex size-20 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-10 text-destructive" />
<div className="flex flex-col items-center gap-5 p-12">
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-8 text-destructive" />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<p className="title-2 text-destructive">{message}</p>
<p className="body-1 text-neutral-70">{description}</p>
<div className="flex flex-col items-center gap-1.5 text-center">
<p className="text-lg font-bold text-neutral-90">{message}</p>
<p className="text-sm text-neutral-60">{description}</p>
</div>
{onRetry && (
<Button variant="primary" size="lg" onClick={onRetry}>

View File

@ -6,9 +6,9 @@ export function Input({ type = 'text', className, ...props }: ComponentProps<'in
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'flex h-10 w-full rounded-xl border border-input bg-background px-3.5 py-2 text-sm transition-all',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'focus-visible:outline-none focus-visible:border-brand-600 focus-visible:ring-1 focus-visible:ring-brand-600',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}

View File

@ -1,7 +1,7 @@
import { Button, Input } from '@/components'
import { useLogin } from '@/features/auth/hooks/useLogin'
const inputClassName = 'h-12 rounded-md px-4 text-base'
const inputClassName = 'h-12 rounded-xl px-4 text-base'
export function LoginForm() {
const {

View File

@ -1,6 +1,7 @@
import { useNavigate } from 'react-router'
import { LogOut } from 'lucide-react'
import { useLogoutMutation, useMeQuery } from '@/apis'
import { Button } from '@/components'
import { cn, interactive } from '@/lib'
// 사이드바 하단: 공급사명 + 로그아웃
export function SidebarFooter() {
@ -15,19 +16,23 @@ export function SidebarFooter() {
}
return (
<div className="flex w-full h-[60px] py-3 px-8 gap-3 items-center">
<div className="flex-1 flex items-center min-w-0 text-sm text-foreground">
<span className="truncate">{user?.supplierName ?? '-'}</span>
<div className="flex w-full shrink-0 items-center gap-2 border-t border-border px-6 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-neutral-90">{user?.supplierName ?? '-'}</p>
<p className="truncate text-xs text-neutral-60">{user?.name ?? '공급 파트너'}</p>
</div>
<Button
variant="secondary"
size="sm"
className="w-[61px] px-2 rounded-[4px] text-xs flex-shrink-0"
<button
type="button"
onClick={handleLogout}
disabled={logout.isPending}
className={cn(
'flex shrink-0 items-center gap-1.5 rounded-xl border border-border bg-white px-3 py-1.5 text-xs font-bold text-neutral-70 hover:bg-neutral-10 disabled:opacity-50',
interactive,
)}
>
<LogOut className="size-3.5" />
</Button>
</button>
</div>
)
}

View File

@ -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 (
<div className="flex-1 w-full min-h-0 pr-[16px]">
<div className="flex-1 w-full min-h-0">
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pl-[140px] pr-[126px] max-[1350px]:pl-[80px] max-[1350px]:pr-[72px] max-[1180px]:pl-[48px] max-[1180px]:pr-[40px]">
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]">
<ChatList />
</div>
</div>
@ -34,7 +36,7 @@ function ChatList() {
if (!chats || chats.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="body-1 text-neutral-70"> .</p>
<p className="text-base text-neutral-60"> .</p>
</div>
)
}
@ -42,7 +44,7 @@ function ChatList() {
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} />
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
))}
{isLoading && <TypingBubble />}
<div ref={bottomRef} />
@ -50,14 +52,15 @@ function ChatList() {
)
}
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터.
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터(AI 말풍선 톤).
function TypingBubble() {
return (
<div className="mb-[56px] pt-[36px]" aria-label="협상 중" role="status">
<div className="inline-flex items-center gap-[6px]">
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.3s]" />
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.15s]" />
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce" />
<div className="mb-6 flex flex-col items-start gap-1.5" aria-label="협상 중" role="status">
<span className="text-xs font-bold text-neutral-80">{AI_LABEL}</span>
<div className="inline-flex items-center gap-1.5 rounded-2xl rounded-tl-none border border-[#D2E6FF] bg-brand-light/70 px-4 py-3.5 shadow-sm">
<span className="size-2 rounded-full bg-brand-500 animate-bounce [animation-delay:-0.3s]" />
<span className="size-2 rounded-full bg-brand-500 animate-bounce [animation-delay:-0.15s]" />
<span className="size-2 rounded-full bg-brand-500 animate-bounce" />
</div>
</div>
)
@ -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 (
<>
<div className="text-right mb-[56px]" />
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
return null
}
}
return <div>{isBot ? <BotMessage message={message} isFirst={isFirst} /> : <UserMessage text={message.script || ''} />}</div>
return isBot ? <BotMessage message={message} /> : <UserMessage text={message.script || ''} />
})
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 (
<div className="mb-[56px]">
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
{/* 봇 스크립트의 `**굵게**` 경량 마크업 해석 (표현 규칙은 프론트 소유 — emphasis.tsx) */}
<div className="body-1-read-r">{renderEmphasis(message.script || '')}</div>
<div className="mb-5 animate-fade-in">
<div className="flex flex-col items-start gap-1.5">
<span className="text-xs font-bold text-neutral-80">{AI_LABEL}</span>
{hasScript && (
<div className="max-w-[80%] rounded-2xl rounded-tl-none border border-[#D2E6FF] bg-brand-light/70 p-4 text-sm leading-relaxed text-neutral-90 shadow-sm break-keep">
{renderEmphasis(message.script || '')}
</div>
)}
</div>
<div className="flex flex-col gap-4 w-full mt-[32px]">
<div className="flex flex-col gap-4 w-full mt-4">
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
<Indicator number={message.indicator_value} />
)}
@ -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 (
<>
<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">
<div className="mb-5 flex flex-col items-end gap-1.5 animate-fade-in">
<span className="text-xs font-bold text-neutral-80">{label}</span>
<div className="max-w-[80%] rounded-2xl rounded-tr-none border border-border bg-white p-4 text-sm leading-relaxed text-neutral-90 shadow-sm break-keep">
{text}
</div>
</div>
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
})

View File

@ -5,9 +5,12 @@ 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]">
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
<ChatMessage />
{/* 하단 액션 덱 */}
<div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
<UserButton {...userButtonConfig} />
</div>
</div>
)
}

View File

@ -8,34 +8,33 @@ export function ItemImage() {
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">
<div className="px-5 pt-5 pb-3">
<button
type="button"
onClick={() => setIsOpen(true)}
aria-label="이미지 크게 보기"
className="relative cursor-pointer group"
className="group relative block aspect-square w-full overflow-hidden rounded-2xl border border-border bg-neutral-10 shadow-inner"
>
<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>
{item_image ? (
<img
src={item_image}
alt="상품 이미지"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-neutral-40">
<ImageIcon size={40} strokeWidth={1.5} />
</div>
)}
<span className="absolute left-2 top-2 rounded-md bg-neutral-90/65 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-sm">
</span>
</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] max-[1180px]:w-[200px] max-[1180px]:h-[200px] object-cover" />
}
return (
<div className="flex w-[220px] h-[220px] max-[1180px]:w-[200px] max-[1180px]:h-[200px] 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()
@ -53,9 +52,9 @@ function ImageModal({ src, onClose }: { src: string; onClose: () => void }) {
>
<div className="relative" onClick={(e) => e.stopPropagation()}>
{src ? (
<img src={src} alt="상품 이미지" className="rounded-[16px] max-w-[42rem] max-h-[90vh] object-contain" />
<img src={src} alt="상품 이미지" className="max-h-[90vh] max-w-[42rem] rounded-2xl 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">
<div className="flex h-[60vmin] max-h-[90vh] w-[60vmin] max-w-[42rem] items-center justify-center rounded-2xl bg-neutral-20 text-neutral-50">
<ImageIcon size={96} strokeWidth={1.25} />
</div>
)}
@ -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"
>
<X size={20} />
<X size={18} />
</button>
</div>
</div>,

View File

@ -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 (
<div className="flex items-start self-stretch gap-1 min-w-0">
<div className={cn(textClass, 'w-[100px]')}>{title}</div>
<div
className={cn(textClass, 'flex-1 min-w-0 break-words break-keep cursor-default')}
<div className="flex items-start justify-between gap-3 self-stretch border-b border-border/60 py-2 last:border-0 min-w-0">
<span className="shrink-0 text-[13px] text-neutral-60">{title}</span>
<span
className="min-w-0 flex-1 break-words break-keep text-right text-[13px] font-semibold text-neutral-90 cursor-default"
onMouseEnter={(e) => displayData !== '-' && showTooltip(e, displayData)}
onMouseLeave={hideTooltip}
>
{displayData}
</div>
</span>
</div>
)
}
@ -94,31 +92,35 @@ function ItemInfo() {
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"
className="mb-3 flex-shrink-0 truncate px-6 text-left text-sm font-bold text-neutral-90 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 overflow-x-hidden pr-6 pl-8 min-h-0">
<div className="mb-2 flex-shrink-0 border-b border-border px-6 pb-2">
<span className="text-[11px] font-extrabold tracking-wider text-neutral-50"> </span>
</div>
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
{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))}
<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"
<div className="flex items-start justify-between gap-3 self-stretch py-2">
<span className="shrink-0 text-sm text-neutral-60"></span>
<span
className="min-w-0 flex-1 overflow-hidden whitespace-normal break-words break-keep text-right text-[13px] font-semibold text-neutral-90 cursor-default"
onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)}
onMouseLeave={hideTooltip}
>
{item_spec || '-'}
</div>
</span>
</div>
</div>
</div>

View File

@ -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(
<div className="fixed inset-0 z-[60] lg:hidden">
<div className="absolute inset-0 bg-black/50 animate-fade-in" onClick={onClose} />
<div
className={cn(
'absolute top-0 bottom-0 flex w-[88%] max-w-[360px] flex-col bg-white shadow-xl',
side === 'left' ? 'left-0' : 'right-0',
)}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-5">
<span className="text-base font-bold text-neutral-90">{title}</span>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
</div>
</div>,
document.body,
)
}

View File

@ -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 <span className="title-1"> : {remaining}</span>
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold whitespace-nowrap',
ended ? 'bg-neutral-20 text-neutral-60' : 'bg-brand-light text-brand-600',
)}
>
<Clock className="size-3.5" />
{remaining}
</span>
)
}

View File

@ -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,23 +7,18 @@ 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])과 오른쪽 끝 정렬
<div className="flex w-full pb-[52px] pr-[16px]">
<div className="grid grid-cols-[1fr_auto_1fr] items-center w-full">
<div />
<div className="flex w-full justify-center overflow-x-auto px-6 py-4 max-[1180px]:px-4">
<div className="flex justify-center">
{type === 'one-black' && <OneBlack text={text || '확인'} />}
{type === 'one-gray' && <OneGray text={text || '확인'} />}
@ -36,10 +30,6 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <LoadingDots />}
</div>
<div className="flex justify-end">
<GoToList />
</div>
</div>
</div>
)
}
@ -57,16 +47,6 @@ function LoadingDots() {
)
}
function GoToList() {
const navigate = useNavigate()
return (
<button className={style.goToList} onClick={() => navigate('/list')} aria-label="상품 목록으로 이동" title="상품 목록">
<List size={24} className="shrink-0" />
<span className="max-[1180px]:hidden"> </span>
</button>
)
}
function OneBlack({ text }: { text: string }) {
const navigate = useNavigate()
const sendMessage = useChatStore((s) => s.sendMessage)

View File

@ -7,22 +7,27 @@ export function Contact() {
const [isOpen, setIsOpen] = useState(false)
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>
<section className="w-full pt-4">
<div className="border-b border-border pb-2">
<span className="text-[11px] font-bold tracking-wide text-neutral-60"></span>
</div>
<div className="flex flex-col gap-2 pt-3">
<button
type="button"
onClick={() => setIsOpen(true)}
className={cn('body-5 text-neutral-80 bg-neutral-40 rounded-[8px] px-[12px] py-[6px] w-fit', interactive)}
className={cn(
'w-fit rounded-lg border border-border bg-white px-3 py-1.5 text-sm font-semibold text-neutral-70 hover:bg-neutral-10',
interactive,
)}
>
</button>
<div className="flex flex-col gap-1">
<div className="body-5 text-neutral-60">010-0000-0000</div>
<div className="body-5 text-neutral-60">o2odev@o2o.kr</div>
<div className="flex flex-col gap-0.5 text-sm text-neutral-60">
<span>010-0000-0000</span>
<span>o2odev@o2o.kr</span>
</div>
</div>
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
</div>
</section>
)
}

View File

@ -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() {
<button
type="button"
onClick={() => setIsOpen(true)}
className={cn(
'flex w-full bg-white rounded-[28px] py-[20px] pl-[24px] pr-[16px] justify-between items-center text-left',
interactive,
)}
className="flex w-full items-center justify-between border-b border-border pb-2 text-left transition-colors hover:border-neutral-40"
>
<span className="menu-title text-neutral-80 break-keep"> </span>
<ChevronRight size={20} className="text-neutral-70" />
<span className="text-[11px] font-bold tracking-wide text-neutral-60 break-keep">
</span>
<ChevronRight size={16} className="text-neutral-50" />
</button>
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
</>

View File

@ -22,39 +22,33 @@ export function MDInformation({ isOpen, setIsOpen }: Props) {
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>
<section className="w-full">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
className="flex w-full items-center justify-between border-b border-border pb-2"
>
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
<span className="text-[11px] font-bold tracking-wide text-neutral-60">MD </span>
<ChevronDown
size={16}
className={cn('text-neutral-50 transition-transform duration-300', !isOpen && '-rotate-90')}
/>
</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')}>
{isOpen && (
<div className="max-h-[220px] animate-fade-in overflow-y-auto pt-3">
<div className="flex flex-col gap-2 break-keep">
{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 key={i} className="flex items-start gap-2 text-sm text-neutral-70">
<span className="shrink-0 text-neutral-50"></span>
<div className="flex-1 whitespace-pre-wrap">{linkText(item)}</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
</section>
)
}
@ -63,7 +57,7 @@ function linkText(text: string) {
const isUrl = (s: string) => /^https?:\/\/[^\s]+$/.test(s)
const parts = text.split(urlRegex)
return (
<div className="body-3 text-neutral-70">
<div className="text-sm text-neutral-70">
{parts.map((part, i) =>
isUrl(part) ? (
<a
@ -71,7 +65,7 @@ function linkText(text: string) {
href={part}
target="_blank"
rel="noopener noreferrer"
className="block text-info underline cursor-pointer hover:brightness-95 transition-all"
className="block text-brand-600 underline transition-all hover:brightness-95"
>
</a>

View File

@ -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 (
<div className="flex flex-col w-[360px] max-[1350px]:w-[320px] max-[1180px]:w-[280px] h-full pt-[64px] pr-[24px]">
<div className="flex flex-col flex-1 gap-[28px]">
<div className={cn(wrap)}>
<div className="flex flex-1 flex-col gap-6">
<MDInformation isOpen={isMDOpen} setIsOpen={setIsMDOpen} />
<NegoStep isOpen={isStepOpen} setIsOpen={setIsStepOpen} />
<Guide />

View File

@ -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 (
<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>
<section className="w-full">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
className="flex w-full items-center justify-between border-b border-border pb-2"
>
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
<span className="text-[11px] font-bold tracking-wide text-neutral-60"></span>
<ChevronDown
size={16}
className={cn('text-neutral-50 transition-transform duration-300', !isOpen && '-rotate-90')}
/>
</button>
</div>
</div>
{isOpen && (
<div className="animate-fade-in pt-4">
<div className="relative">
{/* 세로 레일 */}
<span className="absolute left-[9px] top-3 bottom-3 w-0.5 bg-neutral-20" />
{STEPS.map((step, i) => {
const done = i < currentIndex
const current = i === currentIndex
return (
<div key={step.name} className="relative flex items-start gap-3 py-1.5">
<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',
'z-10 mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-all',
done && 'bg-success text-white',
current && 'scale-110 bg-brand-600 text-white ring-4 ring-brand-light',
!done && !current && 'border border-border bg-white text-neutral-50',
)}
>
<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>
{done ? <Check className="size-3" strokeWidth={3} /> : i + 1}
</div>
<div className="min-w-0">
<p
className={cn(
'text-sm leading-tight',
current && 'font-extrabold text-brand-600',
done && 'font-semibold text-success',
!done && !current && 'font-medium text-neutral-80',
)}
>
{step.name}
</p>
<p className={cn('mt-0.5 text-[11px] leading-snug', current ? 'text-brand-600/80' : 'text-neutral-50')}>
{step.desc}
</p>
</div>
</div>
)
})}
</div>
</div>
)}
</section>
)
}

View File

@ -23,7 +23,7 @@ export function GuideContent() {
return (
<div className="flex w-full flex-col">
<div className="headline-3 mb-10 break-keep text-center text-neutral-90">
<div className="mb-8 break-keep text-center text-lg font-bold text-neutral-90">
</div>

View File

@ -7,19 +7,19 @@ import { GuideContent, GuideContactBox } from './GuideContent'
export function ServiceGuidePopup({ onClose }: { onClose: () => void }) {
return (
<Modal onClose={onClose}>
<div className="flex max-h-[90vh] w-full max-w-[1000px] flex-col items-end gap-6 overflow-y-auto rounded-3xl bg-white px-12 pb-[56px] pt-8">
<div className="m-4 flex max-h-[90vh] w-full max-w-[720px] flex-col items-end gap-4 overflow-y-auto rounded-2xl border border-border bg-white px-6 pb-10 pt-6 shadow-xl animate-scale-in sm:px-12">
<button
type="button"
aria-label="닫기"
onClick={onClose}
className={cn(
'flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-neutral-10',
'flex size-9 flex-shrink-0 items-center justify-center rounded-full bg-neutral-10 text-neutral-70 hover:bg-neutral-20',
interactive,
)}
>
<X size={20} className="text-neutral-80" />
<X size={20} />
</button>
<div className="flex w-full flex-col gap-6 px-[40px]">
<div className="flex w-full flex-col gap-6">
<GuideContent />
<GuideContactBox />
</div>

View File

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.40)] p-4">
<div className="flex max-h-[90vh] w-full max-w-[1000px] flex-col overflow-y-auto rounded-[24px] bg-white px-[80px] pb-[48px] pt-[56px]">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]">
<div className="flex max-h-[90vh] w-full max-w-[720px] flex-col overflow-y-auto rounded-2xl border border-border bg-white px-6 pb-6 pt-8 shadow-xl animate-scale-in sm:px-12 sm:pt-10">
<div className="mb-6 flex w-full flex-col gap-5">
<GuideContent />
<GuideContactBox />
</div>
<div className="flex w-full justify-center gap-3">
<div className="flex w-full justify-center gap-2 sm:gap-3">
<button type="button" className={buttonStyle} onClick={onNeverShowAgain}>
</button>

View File

@ -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 (
<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 className="w-full rounded-2xl border-2 border-brand-light bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
<CheckCircle2 className="size-5 text-success" />
<h3 className="text-sm font-bold text-neutral-90"> </h3>
</div>
)
}
function BidItem({ title, value }: { title: string; value: string }) {
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1 whitespace-pre-wrap">
<span className="body-1-read-b">{title} :</span>
<div className="flex body-1-read-r">&nbsp;{value}</div>
</div>
</div>
)
}
function BidPrice({ number, isVAT, title }: { number: number; isVAT: boolean; title: string }) {
const numberString = number.toLocaleString()
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1">
<div className="body-1-read-b flex flex-shrink-0">{title} :</div>
<div className="flex flex-1 flex-wrap">
<span className="body-1-read-b text-negative break-keep">&nbsp;{numberString}</span>
<span className="body-1-read-b text-neutral-90 break-keep">
&nbsp;({numberToKorean(parseInt(numberString.replace(/,/g, '')))})
<div className="divide-y divide-border/60 rounded-xl border border-border">
<Row label="상품명" value={itemName || '-'} />
<Row label="상품 코드" value={itemCode || '-'} mono />
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{priceStr}</span>
<span className="text-neutral-70"> ({numberToKorean(bidPrice || 0)})</span>
<span className="text-neutral-70"> {isVAT ? 'VAT포함' : 'VAT별도'}</span>
</span>
<span className="body-1-read-b text-neutral-90 break-keep">&nbsp;{isVAT ? 'VAT포함' : 'VAT별도'}</span>
</div>
<Row label="배송형태" value={deliveryType || '-'} />
</div>
</div>
)
}
function Dot() {
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex justify-end items-center w-[26px] h-[30px]">
<p className="body-1-read-r"></p>
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span
className={`min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90 ${mono ? 'font-mono' : ''}`}
>
{value}
</span>
</div>
)
}

View File

@ -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'
// 협상 성공률 카드 (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 (
<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]',
)}
>
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-neutral-90"> </h3>
<span className="font-mono text-base font-extrabold" style={{ color }}>
{percent}%
</span>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export function Indicator({ number }: { number: number }) {
const percent = Math.min(Math.max(number, 0), 100)
return (
<div className="flex w-full flex-col gap-3 bg-neutral-00 rounded-[28px] px-10 py-[36px]">
<h1 className="title-1 text-neutral-90"> </h1>
<div className="flex flex-col gap-2">
{percent === 0 ? (
<div className="relative h-6">
<div className="absolute left-[20px] -translate-x-1/2 text-xl text-black font-bold">&nbsp;0%</div>
</div>
) : (
<div className="h-6" />
)}
<div className="flex gap-1">
<div className="mt-3 flex gap-1">
{Array.from({ length: 10 }).map((_, i) => (
<UnitBlock key={i} blockIndex={i} percent={percent} />
<div
key={i}
className="h-3.5 flex-1 rounded-[3px] transition-colors duration-300"
style={{ backgroundColor: i < filled ? color : 'var(--neutral-20)' }}
/>
))}
</div>
</div>
<p className="mt-2 text-xs text-neutral-60">
.
</p>
</div>
)
}

View File

@ -39,19 +39,20 @@ export function OtherReason({
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
<div className="flex flex-col gap-1.5">
<div className="flex items-start gap-2.5">
<label className={cn('flex shrink-0 items-start gap-2.5', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
<span
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',
'mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isChecked ? 'border-brand-600' : isError ? 'border-negative' : 'border-neutral-40',
disabled && 'opacity-60',
)}
>
{isChecked && <span className="size-2.5 rounded-full bg-brand-600" />}
</span>
<input
className="sr-only"
type="radio"
name="rejectrsp"
value="기타"
@ -59,15 +60,15 @@ export function OtherReason({
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<div className={cn('reject break-keep whitespace-nowrap mt-[4px] flex', disabled && 'text-neutral-60')}>
</div>
<span className={cn('text-sm text-neutral-80', disabled && 'text-neutral-50')}></span>
</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',
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
isTextareaDisabled
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
errorMessage && 'border-negative',
)}
value={inputValue}
@ -75,10 +76,10 @@ export function OtherReason({
placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요."
disabled={isTextareaDisabled}
rows={1}
style={{ minHeight: '35px', lineHeight: '20px' }}
style={{ minHeight: '38px', lineHeight: '20px' }}
/>
</div>
{errorMessage && <div className="body-5 text-negative pl-[65px]">{errorMessage}</div>}
{errorMessage && <div className="pl-[30px] text-xs font-medium text-negative">{errorMessage}</div>}
</div>
)
}

View File

@ -1,4 +1,5 @@
import { useState, useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { findRestoreScript, extractPart } from '@/features/chat/lib/rejectForm'
@ -51,44 +52,47 @@ export function RejectCM() {
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 className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-base font-bold text-[#E5484D]">
<AlertTriangle className="size-5 shrink-0" />
</div>
<div className="reject break-keep py-2">
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
.
<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 w-full items-start gap-3">
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90"> </div>
<div className="flex w-full flex-wrap items-center gap-2 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',
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isDisabled
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
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 className="whitespace-nowrap text-sm text-neutral-70">{isVAT && '(VAT 별도)'}</div>
</div>
<div className="reject-gray whitespace-nowrap">[{koreanPrice} ]</div>
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} ]</div>
</div>
</div>
{priceErrorMessage && <div className="body-5 text-negative pl-[126px]">{priceErrorMessage}</div>}
{priceErrorMessage && <div className="pl-[112px] text-xs font-medium text-negative">{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">
<div className="flex w-full gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90"> </div>
<div className="flex w-full flex-wrap items-center gap-x-6 gap-y-2">
{['협력사배송', '지정택배배송', '픽업배송'].map((opt) => (
<SelectRadio
key={opt}

View File

@ -1,4 +1,5 @@
import { useState, useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useChatStore } from '@/features/chat/stores/useChatStore'
@ -80,12 +81,13 @@ export function RejectRSP() {
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 className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-base font-bold text-[#E5484D]">
<AlertTriangle className="size-5 shrink-0" />
</div>
<div className="reject break-keep py-2">
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
. .
<br />
,
@ -93,31 +95,33 @@ export function RejectRSP() {
</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 w-full items-start gap-3">
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90"> </div>
<div className="flex w-full flex-wrap items-center gap-2 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',
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isDisabled
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
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 className="whitespace-nowrap text-sm text-neutral-70">{isVAT && '(VAT 별도)'}</div>
</div>
<div className="reject-gray whitespace-nowrap">[{koreanPrice} ]</div>
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} ]</div>
</div>
</div>
{priceErrorMessage && <div className="body-5 text-negative pl-[126px]">{priceErrorMessage}</div>}
{priceErrorMessage && <div className="pl-[112px] text-xs font-medium text-negative">{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 gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90"> </div>
<div className="flex w-full flex-col mt-2 gap-2">
{REASONS.map((r) => (
<SelectRadio

View File

@ -1,49 +1,82 @@
import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { formatLeadTime } from '@/features/chat/lib/format'
import type { ChatSummary } from '@/features/chat/types'
// 협상 결과 요약 카드
export function Summary({ data }: { data: ChatSummary }) {
const priceStr = (data.final_price || 0).toLocaleString()
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 className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<CheckCircle2 className="size-5 text-success" />
<h3 className="text-sm font-bold text-neutral-90"> </h3>
</div>
<div className="flex flex-col">
<div className="body-1-read-r"> : {formatKoreanDateTime(data.nego_start_date)}</div>
<div className="body-1-read-r"> : {formatKoreanDateTime(data.nego_end_date)}</div>
<div className="body-1-read-r">
: {data.supplier_name || '-'} ({data.supplier_manager_phone || '-'}){' '}
{data.supplier_manager_email || '-'}
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
. ·
.
</p>
{/* 개요 */}
<div className="mt-4 space-y-1.5 rounded-xl bg-neutral-10 p-3.5 text-xs text-neutral-70">
<p> : {formatKoreanDateTime(data.nego_start_date)}</p>
<p> : {formatKoreanDateTime(data.nego_end_date)}</p>
<p>
: <span className="font-semibold text-neutral-90">{data.supplier_name || '-'}</span> (
{data.supplier_manager_phone || '-'}) {data.supplier_manager_email || '-'}
</p>
</div>
<div className="body-1-read-r"> </div>
{/* 상세 내역 */}
<div className="mt-3 divide-y divide-border/60 rounded-xl border border-border">
<Row label="상품 코드" value={data.item_code || '-'} mono />
<Row label="상품명" value={data.item_name || '-'} />
<Row label="모델명" value={data.item_model || '-'} />
<Row label="제품 규격" value={data.item_spec || '-'} />
<Row label="최소 주문" value={data.item_moq || '-'} />
<Row label="배송 형태" value={data.item_delivery_type || '-'} />
<Row label="배송 리드타임" value={formatLeadTime(data.item_lead_time) || '-'} />
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{priceStr}</span>
<span className="text-neutral-70"> ({numberToKorean(data.final_price || 0)})</span>
<span className="text-neutral-70"> {data.item_isVAT ? 'VAT포함' : 'VAT별도'}</span>
</span>
</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={formatLeadTime(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 ({addOneYear(data.nego_end_date)})
</div>
<div className="body-1-read-r">
{/* 계약 / 담당 MD */}
<div className="mt-3 space-y-1 text-xs text-neutral-70">
<p> 기간: 협상 1 ({addOneYear(data.nego_end_date)})</p>
<p>
MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
</p>
</div>
</div>
<div className="body-1-read-b"> .</div>
<p className="mt-4 rounded-xl bg-brand-light px-4 py-3 text-sm font-bold text-brand-600 break-keep">
.
</p>
</div>
)
}
// ISO 시각 → 한국시(KST) 기준 부분값. 백엔드는 UTC(ISO)로 내려주므로 표시 시 KST 로 변환한다.
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span
className={`min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90 ${mono ? 'font-mono' : ''}`}
>
{value}
</span>
</div>
)
}
// ISO 시각 → KST 기준 부분값.
function kstParts(iso: string): Record<string, string> | null {
if (!iso) return null
const d = new Date(iso)
@ -63,55 +96,16 @@ function kstParts(iso: string): Record<string, string> | null {
return out
}
// "YYYY년 MM월 dd일 HH시 MM분 SS초" (KST)
// "YYYY년 MM월 dd일 HH시 MM분" (KST)
function formatKoreanDateTime(iso: string): string {
const p = kstParts(iso)
if (!p) return '-'
return `${p.year}${p.month}${p.day}${p.hour}${p.minute} ${p.second}`
return `${p.year}${p.month}${p.day}${p.hour}${p.minute}`
}
// 협상 종료 시각 + 1년 → "YYYY년 MM월 dd일" (공급 계약 만료일)
// 협상 종료 시각 + 1년 → "YYYY년 MM월 dd일"
function addOneYear(iso: string): string {
const p = kstParts(iso)
if (!p) return '-'
return `${parseInt(p.year) + 1}${p.month}${p.day}`
}
function DetailText({ title, value }: { title: string; value: string }) {
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex-1 whitespace-pre-wrap">
<span className="body-1-read-b">{title} :&nbsp;</span>
<span className="body-1-read-r">{value}</span>
</div>
</div>
)
}
function PriceText({ price, isVAT }: { price: number; isVAT: boolean }) {
const numberString = price.toLocaleString()
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1">
<div className="body-1-read-b flex flex-shrink-0"> :</div>
<div className="flex flex-1 flex-wrap">
<span className="body-1-read-b text-negative break-keep">&nbsp;{numberString}</span>
<span className="body-1-read-b text-neutral-90 break-keep">
&nbsp;({numberToKorean(parseInt(numberString.replace(/,/g, '')))})
</span>
<span className="body-1-read-b text-neutral-90 break-keep">&nbsp;{isVAT ? 'VAT포함' : 'VAT별도'}</span>
</div>
</div>
</div>
)
}
function Dot() {
return (
<div className="flex justify-end items-center w-[26px] h-[30px]">
<p className="body-1-read-r"></p>
</div>
)
}

View File

@ -22,17 +22,18 @@ export function SelectRadio({
}) {
const isChecked = selectedValue === value
return (
<label className={cn('flex items-center gap-2', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
<input
<label className={cn('flex items-start gap-2.5', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
<span
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',
'mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isChecked ? 'border-brand-600' : errorMessage ? 'border-negative' : 'border-neutral-40',
disabled && 'opacity-60',
)}
>
{isChecked && <span className="size-2.5 rounded-full bg-brand-600" />}
</span>
<input
className="sr-only"
type="radio"
name={name}
value={value}
@ -40,7 +41,9 @@ export function SelectRadio({
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<div className={cn('reject break-keep', disabled && 'text-neutral-60')}>{text}</div>
<span className={cn('text-sm leading-relaxed text-neutral-80 break-keep', disabled && 'text-neutral-50')}>
{text}
</span>
</label>
)
}
@ -59,15 +62,15 @@ export function SubmitButton({
if (isSubmitted) return null
const style = isLoading
? 'bg-neutral-40 text-neutral-80 cursor-not-allowed'
? 'bg-neutral-20 text-neutral-60 cursor-not-allowed'
: isValid
? 'bg-neutral-90 text-white cursor-pointer hover:bg-neutral-80'
: 'bg-neutral-20 text-neutral-60 cursor-not-allowed'
? 'bg-brand-600 text-white shadow-sm cursor-pointer hover:bg-brand-700 active:scale-[0.98]'
: 'bg-neutral-20 text-neutral-50 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',
'flex h-12 w-full max-w-[280px] items-center justify-center rounded-xl text-sm font-bold transition-all duration-200',
style,
)}
onClick={onSubmit}

View File

@ -24,7 +24,7 @@ function InputWithUnit({
const errorId = useId()
return (
<div className="relative flex flex-col gap-3">
<div className="relative flex items-center min-w-[480px] max-[1350px]:min-w-[420px] max-[1180px]:min-w-[320px]">
<div className="relative flex items-center min-w-[220px] sm:min-w-[360px] min-[1180px]:min-w-[420px] min-[1350px]:min-w-[480px]">
<input
ref={inputRef}
type="text"
@ -32,27 +32,27 @@ function InputWithUnit({
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]"
className="h-[52px] flex-1 bg-neutral-10 rounded-xl outline-none border-2 border-brand-600 text-lg font-bold text-neutral-90 placeholder:text-sm placeholder:font-normal placeholder:text-neutral-50 pl-4 pr-[124px]"
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">
<div className="absolute left-4 text-lg font-bold 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"
className="absolute right-1.5 flex items-center justify-center min-w-[104px] h-[40px] px-6 bg-brand-600 hover:bg-brand-700 rounded-lg text-sm font-bold text-white shadow-sm cursor-pointer whitespace-nowrap transition-colors duration-200 active:scale-[0.98]"
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">
<div id={errorId} role="alert" className="absolute top-[62px] left-1/2 -translate-x-1/2 text-sm font-semibold text-negative w-full text-center">
{error}
</div>
)}

View File

@ -0,0 +1,16 @@
import { create } from 'zustand'
// 모바일(lg 미만) 협상 화면에서 좌/우 패널을 드로어로 여닫는 상태.
type Drawer = 'product' | 'status' | null
interface ChatUiStore {
drawer: Drawer
open: (drawer: Exclude<Drawer, null>) => void
close: () => void
}
export const useChatUiStore = create<ChatUiStore>((set) => ({
drawer: null,
open: (drawer) => set({ drawer }),
close: () => set({ drawer: null }),
}))

View File

@ -1,49 +0,0 @@
import { cn, interactive } from '@/lib'
const PILL =
'flex items-center justify-center w-full max-w-[130px] h-[50px] rounded-full py-4 px-6 ' +
'text-lg font-semibold whitespace-nowrap ' +
interactive
export interface ActionSectionProps {
isParticipating: boolean
isRejecting: boolean
onParticipate: () => void
onReject: () => void
}
export function ActionSection({
isParticipating,
isRejecting,
onParticipate,
onReject,
}: ActionSectionProps) {
return (
<div className="flex w-full py-[35px] items-center justify-end gap-3">
<button
type="button"
onClick={onParticipate}
disabled={isParticipating}
className={cn(
PILL,
'bg-primary text-primary-foreground',
isParticipating && 'opacity-50 cursor-not-allowed',
)}
>
</button>
<button
type="button"
onClick={onReject}
disabled={isRejecting}
className={cn(
PILL,
'bg-background text-primary border border-primary',
isRejecting && 'opacity-50 cursor-not-allowed',
)}
>
</button>
</div>
)
}

View File

@ -1,69 +0,0 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn, interactive } from '@/lib'
export interface FilterGroupProps {
title: string
items: readonly string[]
selectedItem: string | null
onItemClick: (item: string) => void
defaultOpen?: boolean
}
export function FilterGroup({
title,
items,
selectedItem,
onItemClick,
defaultOpen = true,
}: FilterGroupProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="flex flex-col w-full">
<div className="flex justify-between items-center h-12 pl-3 pr-1">
<h3 className="text-2xl font-bold leading-[30px] text-foreground whitespace-nowrap">
{title}
</h3>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn('w-10 h-10 flex items-center justify-center text-muted-foreground', interactive)}
>
<ChevronDown
size={24}
className={cn('transition-transform duration-300', !open && 'rotate-180')}
/>
</button>
</div>
{/* grid-rows 0fr→1fr 펼침 */}
<div
className={cn(
'grid transition-[grid-template-rows,opacity] duration-300 ease-out',
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="flex flex-col">
{items.map((item) => (
<button
key={item}
type="button"
onClick={() => onItemClick(item)}
className={cn(
'h-11 px-3 flex items-center rounded-lg text-left text-base text-foreground',
'whitespace-nowrap cursor-pointer transition active:scale-[0.98]',
selectedItem === item ? 'bg-secondary' : 'hover:bg-muted',
)}
>
{item}
</button>
))}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,54 @@
import { AlertTriangle, CheckCircle2, FileText, RefreshCw } from 'lucide-react'
import { cn } from '@/lib'
import { useSessionCounts, type KpiCount } from '@/features/list/hooks/useSessionCounts'
// key → 아이콘/색 스타일 (콕핏 카드)
const STYLE: Record<string, { icon: typeof FileText; tile: string; iconColor: string; spin?: boolean }> = {
created: { icon: FileText, tile: 'bg-brand-light', iconColor: 'text-brand-600' },
progress: { icon: RefreshCw, tile: 'bg-sky-50', iconColor: 'text-sky-500', spin: true },
done: { icon: CheckCircle2, tile: 'bg-emerald-50', iconColor: 'text-success' },
rejected: { icon: AlertTriangle, tile: 'bg-red-50', iconColor: 'text-[#FF4D4F]' },
}
interface KpiCardsProps {
selectedStatus: string | null
onSelect: (status: string) => void
}
export function KpiCards({ selectedStatus, onSelect }: KpiCardsProps) {
const counts = useSessionCounts()
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{counts.map((c) => (
<Card key={c.key} data={c} active={selectedStatus === c.status} onClick={() => onSelect(c.status)} />
))}
</div>
)
}
function Card({ data, active, onClick }: { data: KpiCount; active: boolean; onClick: () => void }) {
const s = STYLE[data.key]
const Icon = s.icon
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex items-center gap-3.5 rounded-2xl border bg-white p-4 text-left transition-all sm:p-5',
'hover:shadow-md active:scale-[0.99]',
active ? 'border-brand-600 ring-2 ring-brand-600/10 shadow-sm' : 'border-border shadow-sm',
)}
>
<div className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl', s.tile)}>
<Icon className={cn('size-5', s.iconColor, s.spin && 'animate-spin-slow')} />
</div>
<div className="min-w-0">
<p className="truncate text-[11px] font-bold text-neutral-60">{data.label}</p>
<p className="text-lg font-extrabold tabular-nums text-neutral-90">
{data.isLoading ? '' : data.count.toLocaleString()}
<span className="ml-0.5 text-xs font-bold text-neutral-60"></span>
</p>
</div>
</button>
)
}

View File

@ -1,6 +1,6 @@
import { cn, interactive } from '@/lib'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib'
import { buildPageItems } from '@/features/list/lib/pagination'
import { ArrowIcon, DotsIcon } from '@/features/list/components/paginationIcons'
interface PaginationProps {
totalPages: number
@ -9,82 +9,41 @@ interface PaginationProps {
}
export function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) {
if (totalPages === 0) {
return <div className="w-full my-[35px] h-[50px]" />
}
if (totalPages <= 1) return null
const items = buildPageItems(currentPage, totalPages)
return (
<div className="flex w-full my-[35px]">
<div className="flex w-full h-[50px] items-center justify-center gap-3">
<NavButton
dir="prev"
enabled={currentPage > 1}
onClick={() => onPageChange(currentPage - 1)}
/>
<div className="flex items-center justify-center h-full rounded-[999px] border border-neutral-30 bg-neutral-00">
<div className="flex items-center justify-center gap-1 h-[38px]">
<div className="flex items-center justify-center gap-1.5 py-1">
<NavButton dir="prev" enabled={currentPage > 1} onClick={() => onPageChange(currentPage - 1)} />
{items.map((item, idx) =>
item === 'dots' ? (
<DotsIcon key={`dots-${idx}`} />
<span key={`dots-${idx}`} className="px-1 text-sm text-neutral-50 select-none">
</span>
) : (
<PageNumber
key={item}
page={item}
active={item === currentPage}
onClick={() => onPageChange(item)}
/>
),
)}
</div>
</div>
<NavButton
dir="next"
enabled={currentPage < totalPages}
onClick={() => onPageChange(currentPage + 1)}
/>
</div>
</div>
)
}
function PageNumber({
page,
active,
onClick,
}: {
page: number
active: boolean
onClick: () => void
}) {
return (
<button
key={item}
type="button"
onClick={active ? undefined : onClick}
onClick={item === currentPage ? undefined : () => onPageChange(item)}
className={cn(
'flex w-[50px] h-[50px] items-center justify-center shrink-0 text-[18px] tracking-[-0.36px] select-none',
active
? 'rounded-full bg-primary text-primary-foreground cursor-default'
: cn('text-neutral-80', interactive),
'flex size-9 items-center justify-center rounded-lg text-sm font-semibold tabular-nums transition-all',
item === currentPage
? 'bg-brand-600 text-white shadow-sm cursor-default'
: 'text-neutral-70 hover:bg-neutral-10 active:scale-[0.96]',
)}
>
{page}
{item}
</button>
),
)}
<NavButton dir="next" enabled={currentPage < totalPages} onClick={() => onPageChange(currentPage + 1)} />
</div>
)
}
function NavButton({
dir,
enabled,
onClick,
}: {
dir: 'prev' | 'next'
enabled: boolean
onClick: () => void
}) {
function NavButton({ dir, enabled, onClick }: { dir: 'prev' | 'next'; enabled: boolean; onClick: () => void }) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight
return (
<button
type="button"
@ -92,11 +51,11 @@ function NavButton({
disabled={!enabled}
onClick={enabled ? onClick : undefined}
className={cn(
'flex items-center justify-center w-[50px] h-[50px] rounded-full border border-neutral-30 bg-neutral-00 select-none',
enabled ? cn('text-neutral-60', interactive) : 'text-neutral-40 cursor-default',
'flex size-9 items-center justify-center rounded-lg border border-border transition-all',
enabled ? 'text-neutral-70 hover:bg-neutral-10 active:scale-[0.96]' : 'text-neutral-40 cursor-default',
)}
>
<ArrowIcon dir={dir} />
<Icon className="size-4" />
</button>
)
}

View File

@ -1,6 +1,7 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { cn, interactive } from '@/lib'
import { cn } from '@/lib'
const REASONS = ['단종', '품절', '기타'] as const
@ -36,32 +37,39 @@ export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
return
}
if (isSubmitDisabled || !selectedReason) return
const reason = isEtcOpen ? customReason.trim() : selectedReason
onSubmit(reason)
onSubmit(isEtcOpen ? customReason.trim() : selectedReason)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="flex h-[500px] w-[700px] flex-col items-center justify-between rounded-[10px] bg-background py-20 shadow-lg transition-all duration-300 ease-out">
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<p className="text-center text-[26px] font-bold leading-9 tracking-[-0.52px] text-foreground">
</p>
<div className="flex items-center justify-between border-b border-border p-5">
<h3 className="text-base font-bold text-neutral-90"> </h3>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
{/* 사유 선택 */}
<div className={cn('flex gap-5 transition-all duration-300 ease-out', isEtcOpen ? 'mt-0' : 'mt-12')}>
{/* 본문 */}
<div className="space-y-4 p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
className={cn(
'flex h-[50px] w-[120px] items-center justify-center rounded-full px-8 title-2 text-foreground',
'bg-neutral-20 border',
interactive,
selectedReason === reason ? 'border-foreground' : 'border-transparent',
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98]',
selectedReason === reason
? 'border-brand-600 bg-brand-light text-brand-600'
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
)}
>
{reason}
@ -69,14 +77,8 @@ export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
))}
</div>
{/* 기타 입력 + 에러 */}
<div className="flex flex-col items-center">
<div
className={cn(
'flex w-[400px] overflow-hidden transition-all duration-300 ease-out',
isEtcOpen ? 'visible h-[120px] opacity-100' : 'invisible h-0 opacity-0',
)}
>
{isEtcOpen && (
<div className="animate-fade-in">
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
@ -84,41 +86,39 @@ export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
setCustomReason(e.target.value)
setShowError(false)
}}
rows={3}
className={cn(
'h-full w-full resize-none rounded-lg bg-background p-2.5 body-3 text-foreground',
'placeholder:text-muted-foreground focus:outline-none',
'transition-colors duration-200',
showError ? 'border-2 border-destructive' : 'border border-foreground',
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:ring-1',
showError
? 'border-destructive focus:border-destructive focus:ring-destructive'
: 'border-border focus:border-brand-600 focus:ring-brand-600',
)}
/>
{showError && <p className="mt-1.5 text-xs font-medium text-destructive"> </p>}
</div>
{/* 에러 메시지 (항상 공간 확보) */}
<div
className={cn(
'w-[400px] overflow-hidden transition-all duration-300 ease-out',
showError && isEtcOpen ? 'mt-2 h-[24px] opacity-100' : 'h-0 opacity-0',
)}
>
<p className="text-left body-5 text-destructive"> </p>
</div>
</div>
{/* 전송 */}
{/* 푸터 */}
<div className="flex gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className={cn(
'flex h-[50px] w-full max-w-[130px] items-center justify-center rounded-full px-8 title-2',
'bg-foreground text-background',
interactive,
isSubmitDisabled && 'cursor-not-allowed opacity-40',
)}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
>
</button>
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,40 @@
import { cn } from '@/lib'
import { STATUS_META } from '@/features/list/lib/status'
// 전체 + 상태별 탭. 값은 원본 상태 라벨(협상생성 등), null = 전체.
const TABS: { label: string; value: string | null }[] = [
{ label: '전체', value: null },
{ label: STATUS_META.협상생성.display, value: '협상생성' },
{ label: STATUS_META.협상중.display, value: '협상중' },
{ label: STATUS_META.협상완료.display, value: '협상완료' },
{ label: STATUS_META.협상거부.display, value: '협상거부' },
{ label: STATUS_META.미참여.display, value: '미참여' },
]
interface StatusTabsProps {
selected: string | null
onSelect: (status: string | null) => void
}
export function StatusTabs({ selected, onSelect }: StatusTabsProps) {
return (
<div className="flex flex-wrap items-center gap-1 rounded-xl bg-neutral-20 p-1">
{TABS.map((t) => {
const active = selected === t.value
return (
<button
key={t.label}
type="button"
onClick={() => onSelect(t.value)}
className={cn(
'rounded-lg px-3.5 py-1.5 text-[13px] font-bold transition-all active:scale-[0.98]',
active ? 'bg-white text-neutral-90 shadow-sm' : 'text-neutral-60 hover:text-neutral-80',
)}
>
{t.label}
</button>
)
})}
</div>
)
}

View File

@ -1,79 +0,0 @@
import { type ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib'
import type { ListItem } from '@/features/list/types'
import { COLUMNS } from '@/features/list/components/tableColumns'
interface TableSectionProps {
items: ListItem[]
selectedId?: string
isLoading?: boolean
onItemClick?: (item: ListItem) => void
}
export function TableSection({ items, selectedId, isLoading, onItemClick }: TableSectionProps) {
return (
<div className="flex flex-1 flex-col w-full min-h-0">
<div className="h-full overflow-auto rounded-[8px] bg-background">
<table className="w-full min-w-[1431px] border-separate border-spacing-0">
<thead className="sticky top-0 z-10">
<tr className="h-[53px] bg-table-header text-foreground text-lg font-semibold">
{COLUMNS.map((col) => (
<th key={col.key} className={cn(col.width, 'px-4 whitespace-nowrap', col.align)}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<StateRow>
<Loader2 className="size-10 animate-spin text-muted-foreground" />
</StateRow>
) : items.length === 0 ? (
<StateRow>
<span className="text-lg text-muted-foreground"> .</span>
</StateRow>
) : (
items.map((item) => (
<tr
key={item.session_id}
onClick={() => onItemClick?.(item)}
className={cn(
'h-[53px] cursor-pointer text-lg text-foreground transition-colors',
item.session_id === selectedId ? 'bg-table-selected' : 'hover:bg-table-hover',
)}
>
{COLUMNS.map((col) => (
<td
key={col.key}
className={cn(
col.width,
'px-4 border-b border-border whitespace-nowrap',
col.align,
)}
>
{col.render ? col.render(item) : item[col.key] || '-'}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
// 로딩/빈 상태 행
function StateRow({ children }: { children: ReactNode }) {
return (
<tr>
<td colSpan={COLUMNS.length} className="h-[240px]">
<div className="flex items-center justify-center">{children}</div>
</td>
</tr>
)
}

View File

@ -0,0 +1,105 @@
import { Loader2 } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib'
import type { ListItem } from '@/features/list/types'
import { statusMeta } from '@/features/list/lib/status'
interface WorkspaceCardsProps {
items: ListItem[]
isLoading?: boolean
busyId?: string | null
onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void
}
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: WorkspaceCardsProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-7 animate-spin text-neutral-50" />
</div>
)
}
if (items.length === 0) {
return <div className="py-16 text-center text-sm text-neutral-60"> .</div>
}
return (
<div className="divide-y divide-border">
{items.map((item) => (
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} />
))}
</div>
)
}
function Card({
item,
busy,
onEnter,
onReject,
}: {
item: ListItem
busy: boolean
onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void
}) {
const meta = statusMeta(item.session_status)
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
const canReject = ['협상생성', '협상중'].includes(item.session_status)
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장'
return (
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="rounded-md bg-neutral-20 px-1.5 py-0.5 text-[11px] font-semibold text-neutral-70">
{item.qt_type || '-'}
</span>
<span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span>
</div>
<p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p>
<p className="mt-0.5 truncate text-xs text-neutral-60">
<span className="font-mono">{item.item_code || '-'}</span>
{item.model_name && <span> · {item.model_name}</span>}
</p>
</div>
<span className={cn('inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
<span className={cn('size-1.5 rounded-full', meta.dot)} />
{meta.display}
</span>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-60">
{item.maker_name && <span> {item.maker_name}</span>}
<span> {formatKstDateTime(item.qt_end_time)}</span>
</div>
{(canEnter || canReject) && (
<div className="mt-3 flex gap-2">
{canReject && (
<button
type="button"
onClick={() => onReject(item)}
className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
</button>
)}
{canEnter && (
<button
type="button"
onClick={() => onEnter(item)}
disabled={busy}
className="flex flex-[2] items-center justify-center gap-1 rounded-lg bg-brand-600 py-2 text-xs font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50"
>
{busy && <Loader2 className="size-3 animate-spin" />}
{enterLabel}
</button>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,132 @@
import { type ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
import { cn, formatKstDateTime } from '@/lib'
import type { ListItem } from '@/features/list/types'
import { statusMeta } from '@/features/list/lib/status'
interface WorkspaceTableProps {
items: ListItem[]
isLoading?: boolean
busyId?: string | null
onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void
}
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }: WorkspaceTableProps) {
return (
<div className="w-full overflow-x-auto">
<table className="w-full min-w-[900px] border-collapse">
<thead>
<tr className="border-b border-border bg-table-header">
<th className={HEAD}></th>
<th className={HEAD}></th>
<th className={HEAD}></th>
<th className={HEAD}></th>
<th className={cn(HEAD, 'text-center')}></th>
<th className={HEAD}></th>
<th className={cn(HEAD, 'text-right')}></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<StateRow>
<Loader2 className="size-8 animate-spin text-neutral-50" />
</StateRow>
) : items.length === 0 ? (
<StateRow>
<span className="text-sm text-neutral-60"> .</span>
</StateRow>
) : (
items.map((item) => (
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} />
))
)}
</tbody>
</table>
</div>
)
}
function Row({
item,
busy,
onEnter,
onReject,
}: {
item: ListItem
busy: boolean
onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void
}) {
const meta = statusMeta(item.session_status)
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
const canReject = ['협상생성', '협상중'].includes(item.session_status)
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장'
return (
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
<td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}>
{item.qt_number || '-'}
</td>
<td className={cn(CELL, 'whitespace-nowrap')}>
<span className="rounded-md bg-neutral-20 px-2 py-0.5 text-xs font-semibold text-neutral-70">
{item.qt_type || '-'}
</span>
</td>
<td className={CELL}>
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
<p className="mt-0.5 text-xs text-neutral-60">
<span className="font-mono">{item.item_code || '-'}</span>
{item.model_name && <span> · {item.model_name}</span>}
</p>
</td>
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{item.maker_name || '-'}</td>
<td className={cn(CELL, 'text-center')}>
<span className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold', meta.badge)}>
<span className={cn('size-1.5 rounded-full', meta.dot)} />
{meta.display}
</span>
</td>
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td>
<td className={CELL}>
<div className="flex items-center justify-end gap-2">
{canReject && (
<button
type="button"
onClick={() => onReject(item)}
className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
</button>
)}
{canEnter ? (
<button
type="button"
onClick={() => onEnter(item)}
disabled={busy}
className="inline-flex items-center gap-1 rounded-lg bg-brand-600 px-3.5 py-1.5 text-xs font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50"
>
{busy && <Loader2 className="size-3 animate-spin" />}
{enterLabel}
</button>
) : (
!canReject && <span className="text-xs text-neutral-50"></span>
)}
</div>
</td>
</tr>
)
}
function StateRow({ children }: { children: ReactNode }) {
return (
<tr>
<td colSpan={7} className="py-20">
<div className="flex items-center justify-center">{children}</div>
</td>
</tr>
)
}

View File

@ -1,32 +0,0 @@
// 페이지네이션 인라인 SVG (색 = currentColor)
const ARROW_PATH = {
prev: 'M26.9995 30.6532L21.3457 24.9995L26.9995 19.3457L28.0532 20.3995L23.4532 24.9995L28.0532 29.5995L26.9995 30.6532Z',
next: 'M23.0005 30.6532L28.6543 24.9995L23.0005 19.3457L21.9468 20.3995L26.5468 24.9995L21.9468 29.5995L23.0005 30.6532Z',
} as const
export function ArrowIcon({ dir }: { dir: 'prev' | 'next' }) {
return (
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d={ARROW_PATH[dir]} fill="currentColor" />
</svg>
)
}
export function DotsIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="select-none text-neutral-60"
>
<path
d="M6.23047 13.5C5.81797 13.5 5.46489 13.3531 5.17122 13.0592C4.87739 12.7656 4.73047 12.4125 4.73047 12C4.73047 11.5875 4.87739 11.2344 5.17122 10.9408C5.46489 10.6469 5.81797 10.5 6.23047 10.5C6.64297 10.5 6.99614 10.6469 7.28997 10.9408C7.58364 11.2344 7.73047 11.5875 7.73047 12C7.73047 12.4125 7.58364 12.7656 7.28997 13.0592C6.99614 13.3531 6.64297 13.5 6.23047 13.5ZM11.9997 13.5C11.5872 13.5 11.2341 13.3531 10.9405 13.0592C10.6466 12.7656 10.4997 12.4125 10.4997 12C10.4997 11.5875 10.6466 11.2344 10.9405 10.9408C11.2341 10.6469 11.5872 10.5 11.9997 10.5C12.4122 10.5 12.7653 10.6469 13.059 10.9408C13.3528 11.2344 13.4997 11.5875 13.4997 12C13.4997 12.4125 13.3528 12.7656 13.059 13.0592C12.7653 13.3531 12.4122 13.5 11.9997 13.5ZM17.769 13.5C17.3565 13.5 17.0033 13.3531 16.7095 13.0592C16.4158 12.7656 16.269 12.4125 16.269 12C16.269 11.5875 16.4158 11.2344 16.7095 10.9408C17.0033 10.6469 17.3565 10.5 17.769 10.5C18.1815 10.5 18.5346 10.6469 18.8282 10.9408C19.1221 11.2344 19.269 11.5875 19.269 12C19.269 12.4125 19.1221 12.7656 18.8282 13.0592C18.5346 13.3531 18.1815 13.5 17.769 13.5Z"
fill="currentColor"
/>
</svg>
)
}

View File

@ -1,45 +0,0 @@
import { type ReactNode } from 'react'
import { formatDateTime } from '@/features/list/lib/datetime'
import type { ListItem } from '@/features/list/types'
// 컬럼 설정 (렌더링은 TableSection)
export interface Column {
key: keyof ListItem
label: string
width: string
align: 'text-left' | 'text-center'
render?: (item: ListItem) => ReactNode
}
const STATUS_COLOR: Record<string, string> = {
: 'text-info font-semibold',
: 'text-foreground font-semibold',
: 'text-success font-semibold',
: 'text-muted-foreground font-semibold',
: 'text-destructive font-semibold',
}
export const COLUMNS: Column[] = [
{ key: 'item_code', label: '상품코드', width: 'w-[8.39%]', align: 'text-center' },
{
key: 'session_status',
label: '상태',
width: 'w-[6.57%]',
align: 'text-center',
render: (item) => (
<span className={STATUS_COLOR[item.session_status]}>{item.session_status || '-'}</span>
),
},
{ key: 'qt_number', label: '견적번호', width: 'w-[14.29%]', align: 'text-center' },
{ key: 'qt_type', label: '구분', width: 'w-[5.52%]', align: 'text-center' },
{ key: 'item_name', label: '상품명', width: 'w-[20.96%]', align: 'text-left' },
{ key: 'model_name', label: '모델명', width: 'w-[22.89%]', align: 'text-left' },
{ key: 'maker_name', label: '제조사', width: 'w-[8.67%]', align: 'text-center' },
{
key: 'qt_end_time',
label: '마감일',
width: 'w-[13.72%]',
align: 'text-center',
render: (item) => formatDateTime(item.qt_end_time),
},
]

View File

@ -1,106 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { getApiErrorMessage, useParticipateMutation, useRejectMutation } from '@/apis'
import { toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList'
import { ActionSection } from '@/features/list/components/ActionSection'
import { RejectPopup } from '@/features/list/components/RejectPopup'
import { TableSection } from '@/features/list/components/TableSection'
import { Pagination } from '@/features/list/components/Pagination'
// 상태별 거부 불가 안내
const REJECT_BLOCKED: Record<string, string> = {
: '미참여 상태인 협상은 거부할 수 없습니다.',
: '협상거부 상태인 협상은 거부할 수 없습니다.',
: '협상완료 상태인 협상은 거부할 수 없습니다.',
}
export function ContentContainer() {
const navigate = useNavigate()
const {
items,
isLoading,
totalPages,
currentPage,
setCurrentPage,
selectedId,
selectedItem,
handleItemClick,
} = useList()
const participate = useParticipateMutation()
const reject = useRejectMutation()
const [isRejectOpen, setIsRejectOpen] = useState(false)
// 선택 검증: 선택된 협상이 없으면 안내 후 false
const requireSelection = (): boolean => {
if (!selectedItem) {
toast.info('상품을 선택해주세요.')
return false
}
return true
}
const handleParticipate = () => {
if (!requireSelection()) return
if (['협상거부', '미참여'].includes(selectedItem!.session_status)) {
toast.error('협상거부 또는 미참여한 협상에는 참여할 수 없습니다.')
return
}
const sessionId = selectedItem!.session_id
participate.mutate(sessionId, {
onSuccess: () => navigate(`/chat?session_id=${sessionId}`),
onError: (error) => toast.error(getApiErrorMessage(error, '협상 참여에 실패했습니다.')),
})
}
const handleReject = () => {
if (!requireSelection()) return
const blocked = REJECT_BLOCKED[selectedItem!.session_status]
if (blocked) {
toast.error(blocked)
return
}
setIsRejectOpen(true)
}
const handleRejectSubmit = (reason: string) => {
if (!selectedItem) return
reject.mutate(
{ sessionId: selectedItem.session_id, request: { reject_reason: reason } },
{
onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
},
)
}
return (
// 좌우 거터(80px) 일괄 적용
<div className="flex flex-1 flex-col min-h-0 px-[80px]">
<ActionSection
isParticipating={participate.isPending}
isRejecting={reject.isPending}
onParticipate={handleParticipate}
onReject={handleReject}
/>
<TableSection
items={items}
isLoading={isLoading}
selectedId={selectedId ?? undefined}
onItemClick={handleItemClick}
/>
<Pagination
totalPages={totalPages}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
{isRejectOpen && (
<RejectPopup onClose={() => setIsRejectOpen(false)} onSubmit={handleRejectSubmit} />
)}
</div>
)
}

View File

@ -1,52 +0,0 @@
import { useShallow } from 'zustand/react/shallow'
import { FilterGroup } from '@/features/list/components/FilterGroup'
import { FILTER_GROUPS, type FilterKey } from '@/features/list/filterOptions'
import { useListStore } from '@/features/list/stores/useListStore'
export function FilterContainer() {
// 필터 필드만 구독 → 페이지 변경 시 리렌더 방지
const {
selectedType,
selectedStatus,
selectedDeadline,
toggleType,
toggleStatus,
toggleDeadline,
} = useListStore(
useShallow((s) => ({
selectedType: s.selectedType,
selectedStatus: s.selectedStatus,
selectedDeadline: s.selectedDeadline,
toggleType: s.toggleType,
toggleStatus: s.toggleStatus,
toggleDeadline: s.toggleDeadline,
})),
)
const selected: Record<FilterKey, string | null> = {
type: selectedType,
status: selectedStatus,
deadline: selectedDeadline,
}
const toggle: Record<FilterKey, (item: string) => void> = {
type: toggleType,
status: toggleStatus,
deadline: toggleDeadline,
}
return (
<div className="flex flex-1 pt-8 pr-2 pl-5 overflow-hidden bg-background">
<div className="flex flex-col gap-4 pr-2 w-full overflow-y-auto">
{FILTER_GROUPS.map((group) => (
<FilterGroup
key={group.key}
title={group.title}
items={group.items}
selectedItem={selected[group.key]}
onItemClick={toggle[group.key]}
/>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,157 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { useShallow } from 'zustand/react/shallow'
import { Search } from 'lucide-react'
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation } from '@/apis'
import { cn, toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList'
import { useListStore } from '@/features/list/stores/useListStore'
import { KpiCards } from '@/features/list/components/KpiCards'
import { StatusTabs } from '@/features/list/components/StatusTabs'
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
import { Pagination } from '@/features/list/components/Pagination'
import { RejectPopup } from '@/features/list/components/RejectPopup'
import type { ListItem } from '@/features/list/types'
// 상태별 거부 불가 안내
const REJECT_BLOCKED: Record<string, string> = {
: '미참여 상태인 협상은 거부할 수 없습니다.',
: '협상거부 상태인 협상은 거부할 수 없습니다.',
: '협상완료 상태인 협상은 거부할 수 없습니다.',
}
const TYPE_OPTIONS = ['재견적', '재협상', '신규견적', '신규협상']
export function ListWorkspace() {
const navigate = useNavigate()
const { data: user } = useMeQuery()
// 리스트는 서버 페이징. (검색은 백엔드 미지원 → 프론트 검색은 비워 둠)
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
const participate = useParticipateMutation()
const reject = useRejectMutation()
const { selectedStatus, setStatus, selectedType, setType } = useListStore(
useShallow((s) => ({
selectedStatus: s.selectedStatus,
setStatus: s.setStatus,
selectedType: s.selectedType,
setType: s.setType,
})),
)
const [search, setSearch] = useState('')
const [enteringId, setEnteringId] = useState<string | null>(null)
const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null)
const handleEnter = (item: ListItem) => {
setEnteringId(item.session_id)
participate.mutate(item.session_id, {
onSuccess: () => navigate(`/chat?session_id=${item.session_id}`),
onError: (error) => {
setEnteringId(null)
toast.error(getApiErrorMessage(error, '협상 참여에 실패했습니다.'))
},
})
}
const handleReject = (item: ListItem) => {
const blocked = REJECT_BLOCKED[item.session_status]
if (blocked) {
toast.error(blocked)
return
}
setRejectTarget(item)
}
const handleRejectSubmit = (reason: string) => {
if (!rejectTarget) return
reject.mutate(
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } },
{
onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
},
)
}
return (
<div className="space-y-5">
{/* 환영 배너 */}
<div className="flex flex-wrap items-center justify-between gap-2 rounded-2xl border border-border bg-white px-5 py-4 shadow-[0_2px_12px_rgba(0,0,0,0.02)]">
<div>
<p className="text-[15px] font-bold text-neutral-90">
{user?.supplierName ? `${user.supplierName} 담당자님, 환영합니다.` : '공급 파트너님, 환영합니다.'}
</p>
<p className="mt-0.5 text-xs text-neutral-60">
.
</p>
</div>
</div>
{/* KPI 콕핏 */}
<KpiCards selectedStatus={selectedStatus} onSelect={(s) => setStatus(selectedStatus === s ? null : s)} />
{/* 워크스페이스 카드 */}
<div className="overflow-hidden rounded-2xl border border-border bg-white shadow-sm">
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
<StatusTabs selected={selectedStatus} onSelect={setStatus} />
<div className="flex w-full items-center gap-2 lg:w-auto">
<select
value={selectedType ?? ''}
onChange={(e) => setType(e.target.value || null)}
className="h-9 shrink-0 rounded-xl border border-border bg-white px-3 text-xs font-semibold text-neutral-80 outline-none transition-all focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
>
<option value=""> </option>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<div className="relative flex-1 lg:flex-none">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="견적번호·상품 검색"
className={cn(
'h-9 w-full rounded-xl border border-border bg-white pl-8 pr-3 text-xs text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:border-brand-600 focus:ring-1 focus:ring-brand-600 lg:w-56',
)}
/>
</div>
</div>
</div>
{/* 데스크톱: 테이블 / 모바일: 카드 스택 */}
<div className="hidden lg:block">
<WorkspaceTable
items={items}
isLoading={isLoading}
busyId={participate.isPending ? enteringId : null}
onEnter={handleEnter}
onReject={handleReject}
/>
</div>
<div className="lg:hidden">
<WorkspaceCards
items={items}
isLoading={isLoading}
busyId={participate.isPending ? enteringId : null}
onEnter={handleEnter}
onReject={handleReject}
/>
</div>
<div className="border-t border-border px-4 py-3">
<Pagination totalPages={totalPages} currentPage={currentPage} onPageChange={setCurrentPage} />
</div>
</div>
{rejectTarget && (
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
)}
</div>
)
}

View File

@ -1,17 +0,0 @@
export type FilterKey = 'type' | 'status' | 'deadline'
export interface FilterGroupConfig {
key: FilterKey
title: string
items: readonly string[]
}
export const FILTER_GROUPS: readonly FilterGroupConfig[] = [
{ key: 'type', title: '구분', items: ['재견적', '재협상', '신규견적', '신규협상'] },
{
key: 'status',
title: '상태',
items: ['협상생성', '협상중', '협상완료', '미참여', '협상거부'],
},
{ key: 'deadline', title: '마감일', items: ['남은 시간 적은 순', '남은 시간 긴 순'] },
]

View File

@ -1,21 +1,13 @@
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useSessionListQuery } from '@/apis'
import { useListStore } from '@/features/list/stores/useListStore'
import {
deadlineToOrder,
statusLabelToCode,
toListItem,
typeLabelToCode,
} from '@/features/list/lib/adapter'
import type { ListItem } from '@/features/list/types'
import { deadlineToOrder, statusLabelToCode, toListItem, typeLabelToCode } from '@/features/list/lib/adapter'
const PAGE_SIZE = 20
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
export function useList() {
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
useListStore()
const [selectedId, setSelectedId] = useState<string | null>(null)
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
const query = useSessionListQuery({
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
@ -26,25 +18,7 @@ export function useList() {
})
const items = useMemo(() => (query.data?.items ?? []).map(toListItem), [query.data])
const total = query.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE))
const selectedItem = useMemo(
() => items.find((item) => item.session_id === selectedId) ?? null,
[items, selectedId],
)
const handleItemClick = (item: ListItem) =>
setSelectedId((prev) => (prev === item.session_id ? null : item.session_id))
return {
items,
isLoading: query.isLoading,
totalPages,
currentPage,
setCurrentPage,
selectedId,
selectedItem,
handleItemClick,
}
return { items, isLoading: query.isLoading, totalPages, currentPage, setCurrentPage }
}

View File

@ -0,0 +1,35 @@
import { useQueries } from '@tanstack/react-query'
import { negotiationApi, negotiationKeys, SessionStatus } from '@/apis'
// KPI 콕핏에 노출할 상태별 집계. 각 상태를 page_size=1 로 조회해 total 만 읽는다(목록과 캐시 공유).
export interface KpiCount {
key: string
label: string
status: string // 클릭 시 필터에 넣을 원본 상태 라벨
code: number
count: number
isLoading: boolean
}
const KPI = [
{ key: 'created', label: '협상 대기', status: '협상생성', code: SessionStatus.CREATED },
{ key: 'progress', label: '조율 진행중', status: '협상중', code: SessionStatus.IN_PROGRESS },
{ key: 'done', label: '타결 완료', status: '협상완료', code: SessionStatus.DONE },
{ key: 'rejected', label: '거부/반려', status: '협상거부', code: SessionStatus.REJECTED },
] as const
export function useSessionCounts(): KpiCount[] {
const results = useQueries({
queries: KPI.map((k) => ({
queryKey: negotiationKeys.sessionList({ status: k.code, page: 1, page_size: 1 }),
queryFn: () => negotiationApi.getSessions({ status: k.code, page: 1, page_size: 1 }),
staleTime: 60 * 1000,
})),
})
return KPI.map((k, i) => ({
...k,
count: results[i].data?.total ?? 0,
isLoading: results[i].isLoading,
}))
}

View File

@ -1,3 +1,2 @@
// 공개 API (페이지에서 쓰는 것만)
export { FilterContainer } from '@/features/list/containers/FilterContainer'
export { ContentContainer } from '@/features/list/containers/ContentContainer'
export { ListWorkspace } from '@/features/list/containers/ListWorkspace'

View File

@ -1,6 +0,0 @@
import { formatKstDateTime } from '@/lib'
// 'YYYY-MM-DD HH:mm' (KST 고정). 백엔드 UTC 직렬화를 한국 시간으로 표시한다.
export function formatDateTime(value: string): string {
return formatKstDateTime(value)
}

View File

@ -0,0 +1,19 @@
// 세션 상태(원본 라벨) → 포털 표시 라벨 + 배지 스타일.
// 원본 라벨은 apis 의 SESSION_STATUS_LABEL(협상생성/협상중/협상완료/미참여/협상거부)과 일치한다.
export interface StatusMeta {
display: string
badge: string // 배지 배경/글자색
dot: string // 상태 점 색
}
export const STATUS_META: Record<string, StatusMeta> = {
: { display: '대기', badge: 'bg-brand-light text-brand-600', dot: 'bg-brand-600' },
: { display: '조율중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' },
: { display: '타결', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' },
: { display: '미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' },
: { display: '거부', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
}
export function statusMeta(label: string): StatusMeta {
return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }
}

View File

@ -7,7 +7,9 @@ interface ListState {
currentPage: number
toggleType: (item: string) => void
setType: (item: string | null) => void
toggleStatus: (item: string) => void
setStatus: (item: string | null) => void
toggleDeadline: (item: string) => void
setCurrentPage: (page: number) => void
resetFilters: () => void
@ -23,8 +25,10 @@ export const useListStore = create<ListState>((set) => ({
toggleType: (item) =>
set((s) => ({ selectedType: toggle(s.selectedType, item), currentPage: 1 })),
setType: (item) => set({ selectedType: item, currentPage: 1 }),
toggleStatus: (item) =>
set((s) => ({ selectedStatus: toggle(s.selectedStatus, item), currentPage: 1 })),
setStatus: (item) => set({ selectedStatus: item, currentPage: 1 }),
toggleDeadline: (item) =>
set((s) => ({ selectedDeadline: toggle(s.selectedDeadline, item), currentPage: 1 })),
setCurrentPage: (page) => set({ currentPage: page }),

View File

@ -2,39 +2,41 @@
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.css');
@import "tailwindcss";
/* 디자인 토큰 (light 전용) */
/* 디자인 토큰 (light 전용) — 토스풍(Toss) 팔레트 */
:root {
color-scheme: light;
--radius: 0.625rem; /* 10px 기준 */
--radius: 0.75rem; /* 12px 기준 (토스풍 라운드) */
/* 브랜드 */
--brand-500: #5a83c1;
--brand-600: #254d8b; /* = --primary */
--brand-700: #1b3965;
/* 브랜드 (Toss blue) */
--brand-500: #6098f5; /* ring / 보조 */
--brand-600: #3182f6; /* = --primary */
--brand-700: #1b64da; /* hover / pressed */
--brand-light: #e8f3ff; /* 연한 브랜드 배경 (선택행·AI 말풍선) */
/* Neutral */
--neutral-90: #151515;
--neutral-80: #303030;
--neutral-70: #606060;
--neutral-60: #808080;
--neutral-40: #dadbde;
--neutral-30: #eaebef;
--neutral-20: #f0f1f4;
--neutral-10: #f7f8fa;
/* Neutral (cool gray) */
--neutral-90: #191f28; /* text-primary */
--neutral-80: #333d4b;
--neutral-70: #4e5968; /* text-secondary */
--neutral-60: #8b95a1; /* text-caption */
--neutral-50: #b0b8c1; /* placeholder */
--neutral-40: #d1d6db;
--neutral-30: #e5e8eb; /* border-card */
--neutral-20: #f2f4f6; /* bg-gray */
--neutral-10: #f9fafb; /* bg-section */
--neutral-00: #ffffff;
/* 상태 / 테이블 */
--negative: #e71c3b;
--info: #4880ef;
--table-header: #e3e8f1;
--table-hover: #dbe6fc;
--table-selected: #b6ccf9;
--info: #3182f6;
--table-header: #f8f9fa;
--table-hover: #f2f4f6;
--table-selected: var(--brand-light);
/* 시맨틱 */
--background: var(--neutral-00);
--foreground: var(--neutral-90);
--surface: #eef0f4; /* 중립 라이트 그레이 (콘텐츠 면, 블루기 최소) */
--surface: #f9fafb; /* 콘텐츠 면 (토스 섹션 배경) */
--card: var(--neutral-00);
--card-foreground: var(--neutral-90);
@ -55,7 +57,7 @@
--ring: var(--brand-500);
--destructive: var(--negative);
--success: #10b981;
--success: #0b8f57;
--warning: #f59e0b;
}
@ -65,12 +67,14 @@
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-light: var(--brand-light);
/* Neutral */
--color-neutral-90: var(--neutral-90);
--color-neutral-80: var(--neutral-80);
--color-neutral-70: var(--neutral-70);
--color-neutral-60: var(--neutral-60);
--color-neutral-50: var(--neutral-50);
--color-neutral-40: var(--neutral-40);
--color-neutral-30: var(--neutral-30);
--color-neutral-20: var(--neutral-20);
@ -120,6 +124,28 @@
--radius-2xl: calc(var(--radius) * 1.8); /* 18px */
--radius-3xl: calc(var(--radius) * 2.2); /* 22px */
--radius-4xl: calc(var(--radius) * 2.6); /* 26px */
/* 애니메이션 (토스풍 미세 전환) */
--animate-fade-in: fadeIn 0.2s ease-out forwards;
--animate-scale-in: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-spin-slow: spin 8s linear infinite;
--animate-pulse-slow: pulseSlow 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulseSlow {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.015); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@layer base {

View File

@ -2,7 +2,7 @@ import { type ReactNode } from 'react'
import { cn } from '@/lib'
const HEADER_BASE =
'flex w-full h-[56px] py-[18px] items-center rounded-t-[8px] bg-primary text-primary-foreground'
'flex w-full h-[56px] items-center bg-white text-foreground border-b border-border'
const HEADER_ALIGN = {
left: 'justify-start',

View File

@ -8,19 +8,19 @@ const SIDEBAR_WIDTH = {
'w-[20%] max-w-[320px] ' +
'max-[1520px]:w-[23%] max-[1410px]:w-[25%] ' +
'max-[1180px]:w-[27%] max-[1024px]:w-[29%] max-[960px]:w-[33%]',
chat: 'w-[350px] max-[1350px]:w-[310px] max-[1180px]:w-[280px]',
chat: 'w-[300px] max-[1350px]:w-[284px] max-[1180px]:w-[260px]',
} as const
const styles = {
root: 'flex w-full min-h-screen max-h-screen bg-background',
scrollX: 'flex w-full min-h-screen max-h-screen overflow-x-auto overflow-y-hidden',
scrollXInner: 'flex w-full min-h-screen max-h-screen min-w-[1024px] bg-background',
sidebar: 'flex flex-col h-screen pt-2 bg-background',
// 좌측 레일은 lg 미만에서 숨기고(드로어로 대체), main 이 전체 폭을 차지한다. 보고서식 풀블리드 + 경계선.
sidebar: 'hidden lg:flex flex-col h-screen bg-white border-r border-border',
logoHeader:
'flex w-full h-[56px] pl-[32px] pr-[24px] py-[8px] items-center justify-between shrink-0',
main: 'flex flex-1 flex-col h-screen overflow-hidden pt-2 pr-2 pb-2 pl-0',
// 다크 헤더와 합쳐져 카드 형태
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden rounded-b-[8px] bg-surface',
'flex w-full h-[56px] pl-[24px] pr-[16px] items-center justify-between shrink-0 border-b border-border',
main: 'flex flex-1 flex-col h-screen overflow-hidden',
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden bg-surface',
} as const
export type SidebarWidth = keyof typeof SIDEBAR_WIDTH

View File

@ -0,0 +1,57 @@
import { useNavigate } from 'react-router'
import { Bell, LogOut } from 'lucide-react'
import { useLogoutMutation, useMeQuery } from '@/apis'
import { Logo } from '@/components'
import { cn, interactive } from '@/lib'
// 포털 상단 네비게이션 바 (목록 화면 전용).
export function PortalHeader() {
const navigate = useNavigate()
const { data: user } = useMeQuery()
const logout = useLogoutMutation()
const handleLogout = () =>
logout.mutate(undefined, { onSuccess: () => navigate('/') })
return (
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6">
<Logo size="md" />
<div className="flex items-center gap-2 sm:gap-3">
<button
type="button"
aria-label="알림"
className={cn(
'relative flex size-9 items-center justify-center rounded-xl text-neutral-70 hover:bg-neutral-10',
interactive,
)}
>
<Bell className="size-5" />
<span className="absolute right-2 top-2 size-1.5 rounded-full bg-brand-600" />
</button>
<div className="hidden min-w-0 flex-col items-end sm:flex">
<span className="max-w-[180px] truncate text-[13px] font-bold text-neutral-90">
{user?.supplierName ?? '-'}
</span>
<span className="text-[11px] text-neutral-60">{user?.name ?? '공급 파트너'}</span>
</div>
<button
type="button"
onClick={handleLogout}
disabled={logout.isPending}
className={cn(
'flex items-center gap-1.5 rounded-xl border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 hover:bg-neutral-10 disabled:opacity-50',
interactive,
)}
>
<LogOut className="size-3.5" />
<span className="hidden sm:inline"></span>
</button>
</div>
</header>
)
}
export default PortalHeader

View File

@ -6,15 +6,16 @@ type ToastOptions = ExternalToast
const baseStyle = {
fontFamily: 'var(--font-sans)',
fontSize: '18px',
fontSize: '14px',
fontWeight: 600,
letterSpacing: '-0.28px',
borderRadius: '8px',
padding: '18px 24px',
borderRadius: '12px',
padding: '12px 16px',
backgroundColor: 'var(--neutral-00)',
color: 'var(--neutral-90)',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.08)',
minWidth: '440px',
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.08)',
width: 'auto',
maxWidth: '90vw',
wordBreak: 'keep-all' as const,
overflowWrap: 'break-word' as const,
whiteSpace: 'pre-wrap' as const,

View File

@ -1,44 +1,103 @@
import { type ReactNode } from 'react'
import { useNavigate, useSearchParams, Navigate } from 'react-router'
import { List } from 'lucide-react'
import { ArrowLeft, Package, ClipboardList } from 'lucide-react'
import { cn, interactive } from '@/lib'
import { MainLayout, MainHeaderBar } from '@/layouts'
import { ChatContainer, ItemSection, RemainingTime } from '@/features/chat'
import { MobileDrawer } from '@/features/chat/components/MobileDrawer'
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
import { useChatUiStore } from '@/features/chat/stores/useChatUiStore'
import { SidebarFooter } from '@/features/auth'
export function ChatPage() {
const [searchParams] = useSearchParams()
const sessionId = searchParams.get('session_id') ?? ''
const drawer = useChatUiStore((s) => s.drawer)
const close = useChatUiStore((s) => s.close)
// session_id 없이 진입하면 목록으로 되돌린다.
if (!sessionId) return <Navigate to="/list" replace />
return (
<>
<MainLayout
sidebarWidth="chat"
scrollX
logoAction={<ListNavButton />}
sidebar={<Sidebar />}
header={
<MainHeaderBar className="px-[140px] max-[1350px]:px-[80px] max-[1180px]:px-[48px]">
<MainHeaderBar className="px-4 sm:px-6 min-[1180px]:px-8">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<BackToListButton />
<span className="hidden h-4 w-px bg-border lg:inline-block" />
<span className="hidden size-2 shrink-0 rounded-full bg-brand-600 animate-pulse lg:inline-block" />
<span className="hidden truncate text-sm font-bold text-neutral-90 lg:inline">
</span>
</div>
<div className="flex items-center gap-1.5">
<PanelToggle icon={<Package className="size-4" />} label="상품" panel="product" />
<PanelToggle icon={<ClipboardList className="size-4" />} label="현황" panel="status" />
<RemainingTime />
</div>
</div>
</MainHeaderBar>
}
>
<ChatContainer sessionId={sessionId} />
</MainLayout>
{/* 모바일 드로어 (lg 미만) */}
<MobileDrawer open={drawer === 'product'} onClose={close} side="left" title="상품 정보">
<ItemSection />
<SidebarFooter />
</MobileDrawer>
<MobileDrawer open={drawer === 'status'} onClose={close} side="right" title="협상 현황">
<MenuSection variant="drawer" />
</MobileDrawer>
</>
)
}
function ListNavButton() {
// 헤더 좌상단: 목록으로 돌아가기 (텍스트 + ← 아이콘)
function BackToListButton() {
const navigate = useNavigate()
return (
<button
type="button"
aria-label="목록으로 이동"
onClick={() => navigate('/list')}
className={cn('text-foreground', interactive)}
className={cn(
'flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-bold text-neutral-70 hover:bg-neutral-10',
interactive,
)}
>
<List size={24} />
<ArrowLeft className="size-4" />
<span className="hidden sm:inline"> </span>
</button>
)
}
// 헤더 우측: 상품/현황 패널 드로어 토글 (모바일 전용)
function PanelToggle({
icon,
label,
panel,
}: {
icon: ReactNode
label: string
panel: 'product' | 'status'
}) {
const open = useChatUiStore((s) => s.open)
return (
<button
type="button"
onClick={() => open(panel)}
className={cn(
'flex items-center gap-1 rounded-lg border border-border px-2 py-1.5 text-xs font-bold text-neutral-70 hover:bg-neutral-10 lg:hidden',
interactive,
)}
>
{icon}
<span className="hidden min-[480px]:inline">{label}</span>
</button>
)
}

View File

@ -1,29 +1,14 @@
import { MainLayout, MainHeaderBar } from '@/layouts'
import { FilterContainer, ContentContainer } from '@/features/list'
import { SidebarFooter } from '@/features/auth'
import { PortalHeader } from '@/layouts/PortalHeader'
import { ListWorkspace } from '@/features/list'
export function ListPage() {
return (
<MainLayout
sidebarWidth="list"
sidebar={<Sidebar />}
header={
<MainHeaderBar align="center" className="px-[81px]">
<span className="text-xl font-bold whitespace-nowrap"> </span>
</MainHeaderBar>
}
>
<ContentContainer />
</MainLayout>
)
}
function Sidebar() {
return (
<>
<FilterContainer />
<SidebarFooter />
</>
<div className="flex min-h-svh flex-col bg-surface">
<PortalHeader />
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">
<ListWorkspace />
</main>
</div>
)
}

View File

@ -10,17 +10,29 @@ export function LoginPage() {
}
return (
<main className="flex min-h-svh w-full items-center justify-center px-6">
<div className="flex w-full max-w-100 flex-col items-center gap-10">
<header className="flex flex-col items-center justify-center gap-4">
<Logo size="md" />
<h1 className="text-2xl font-bold leading-[30px] tracking-[-0.48px]">
<main className="flex min-h-svh w-full items-center justify-center bg-surface px-6">
<div className="flex w-full max-w-sm flex-col items-center gap-6">
{/* 브랜드 락업 */}
<div className="flex flex-col items-center gap-2">
<Logo size="lg" />
<p className="text-[13px] font-medium tracking-[-0.26px] text-neutral-60">
B2B ·
</p>
</div>
{/* 로그인 카드 */}
<div className="w-full rounded-3xl border border-border/60 bg-white p-8 shadow-[0_4px_24px_rgba(0,0,0,0.04)]">
<h1 className="mb-6 text-center text-xl font-bold tracking-[-0.4px] text-neutral-90">
</h1>
</header>
<LoginForm />
</div>
{/* 푸터 */}
<p className="text-xs font-medium text-neutral-60">
문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr
</p>
</div>
</main>
)
}