[feat] negosium/front: 아이마켓 보고서 기준 토스풍 UI 리뉴얼 — 로그인·포털 목록(KPI·모바일 카드)·협상방 3단(말풍선·타임라인·결과/성공률 카드)·모바일 드로어, 목록 서버 페이징 유지·검색 비움
This commit is contained in:
parent
03c22f9474
commit
2386cb8867
@ -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> = {
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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]" />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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>
|
||||
|
||||
50
frontend/src/features/chat/components/MobileDrawer.tsx
Normal file
50
frontend/src/features/chat/components/MobileDrawer.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)} />}
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"> {value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BidPrice({ number, isVAT, title }: { number: number; isVAT: boolean; title: string }) {
|
||||
const numberString = number.toLocaleString()
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Dot />
|
||||
<div className="flex flex-1">
|
||||
<div className="body-1-read-b flex flex-shrink-0">{title} :</div>
|
||||
<div className="flex flex-1 flex-wrap">
|
||||
<span className="body-1-read-b text-negative break-keep"> {numberString}원</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep">
|
||||
({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원)
|
||||
<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"> {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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">▼ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} : </span>
|
||||
<span className="body-1-read-r">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PriceText({ price, isVAT }: { price: number; isVAT: boolean }) {
|
||||
const numberString = price.toLocaleString()
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Dot />
|
||||
<div className="flex flex-1">
|
||||
<div className="body-1-read-b flex flex-shrink-0">최종 협의 가격 :</div>
|
||||
<div className="flex flex-1 flex-wrap">
|
||||
<span className="body-1-read-b text-negative break-keep"> {numberString}원</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep">
|
||||
({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원)
|
||||
</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep"> {isVAT ? 'VAT포함' : 'VAT별도'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Dot() {
|
||||
return (
|
||||
<div className="flex justify-end items-center w-[26px] h-[30px]">
|
||||
<p className="body-1-read-r">•</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
16
frontend/src/features/chat/stores/useChatUiStore.ts
Normal file
16
frontend/src/features/chat/stores/useChatUiStore.ts
Normal 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 }),
|
||||
}))
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
54
frontend/src/features/list/components/KpiCards.tsx
Normal file
54
frontend/src/features/list/components/KpiCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
40
frontend/src/features/list/components/StatusTabs.tsx
Normal file
40
frontend/src/features/list/components/StatusTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
105
frontend/src/features/list/components/WorkspaceCards.tsx
Normal file
105
frontend/src/features/list/components/WorkspaceCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
frontend/src/features/list/components/WorkspaceTable.tsx
Normal file
132
frontend/src/features/list/components/WorkspaceTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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),
|
||||
},
|
||||
]
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
157
frontend/src/features/list/containers/ListWorkspace.tsx
Normal file
157
frontend/src/features/list/containers/ListWorkspace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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: ['남은 시간 적은 순', '남은 시간 긴 순'] },
|
||||
]
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
35
frontend/src/features/list/hooks/useSessionCounts.ts
Normal file
35
frontend/src/features/list/hooks/useSessionCounts.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
import { formatKstDateTime } from '@/lib'
|
||||
|
||||
// 'YYYY-MM-DD HH:mm' (KST 고정). 백엔드 UTC 직렬화를 한국 시간으로 표시한다.
|
||||
export function formatDateTime(value: string): string {
|
||||
return formatKstDateTime(value)
|
||||
}
|
||||
19
frontend/src/features/list/lib/status.ts
Normal file
19
frontend/src/features/list/lib/status.ts
Normal 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' }
|
||||
}
|
||||
@ -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 }),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
57
frontend/src/layouts/PortalHeader.tsx
Normal file
57
frontend/src/layouts/PortalHeader.tsx
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user