Merge branch 'design/frontend' into feature/landing
This commit is contained in:
commit
e93820a51b
@ -10,18 +10,18 @@ export type ButtonVariant =
|
|||||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
export type ButtonSize = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
const base =
|
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 +
|
interactive +
|
||||||
' focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' +
|
' focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' +
|
||||||
'disabled:pointer-events-none disabled:opacity-50'
|
'disabled:pointer-events-none disabled:opacity-50'
|
||||||
|
|
||||||
const variantClass: Record<ButtonVariant, string> = {
|
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',
|
secondary: 'bg-secondary text-secondary-foreground',
|
||||||
outline: 'border border-input bg-background',
|
outline: 'border border-border bg-background text-foreground',
|
||||||
// 투명 배경이라 brightness 무효 → bg 하이라이트
|
// 투명 배경이라 brightness 무효 → bg 하이라이트
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
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> = {
|
const sizeClass: Record<ButtonSize, string> = {
|
||||||
|
|||||||
@ -18,13 +18,13 @@ export function ErrorPage({
|
|||||||
}: ErrorPageProps) {
|
}: ErrorPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<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 flex-col items-center gap-5 p-12">
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-destructive/10">
|
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||||
<AlertTriangle className="size-10 text-destructive" />
|
<AlertTriangle className="size-8 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||||
<p className="title-2 text-destructive">{message}</p>
|
<p className="text-lg font-bold text-neutral-90">{message}</p>
|
||||||
<p className="body-1 text-neutral-70">{description}</p>
|
<p className="text-sm text-neutral-60">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button variant="primary" size="lg" onClick={onRetry}>
|
<Button variant="primary" size="lg" onClick={onRetry}>
|
||||||
|
|||||||
@ -6,9 +6,9 @@ export function Input({ type = 'text', className, ...props }: ComponentProps<'in
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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',
|
'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',
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Button, Input } from '@/components'
|
import { Button, Input } from '@/components'
|
||||||
import { useLogin } from '@/features/auth/hooks/useLogin'
|
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() {
|
export function LoginForm() {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
import { LogOut } from 'lucide-react'
|
||||||
import { useLogoutMutation, useMeQuery } from '@/apis'
|
import { useLogoutMutation, useMeQuery } from '@/apis'
|
||||||
import { Button } from '@/components'
|
import { cn, interactive } from '@/lib'
|
||||||
|
|
||||||
// 사이드바 하단: 공급사명 + 로그아웃
|
// 사이드바 하단: 공급사명 + 로그아웃
|
||||||
export function SidebarFooter() {
|
export function SidebarFooter() {
|
||||||
@ -15,19 +16,23 @@ export function SidebarFooter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-[60px] py-3 px-8 gap-3 items-center">
|
<div className="flex w-full shrink-0 items-center gap-2 border-t border-border px-6 py-3">
|
||||||
<div className="flex-1 flex items-center min-w-0 text-sm text-foreground">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="truncate">{user?.supplierName ?? '-'}</span>
|
<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>
|
</div>
|
||||||
<Button
|
<button
|
||||||
variant="secondary"
|
type="button"
|
||||||
size="sm"
|
|
||||||
className="w-[61px] px-2 rounded-[4px] text-xs flex-shrink-0"
|
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
disabled={logout.isPending}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, memo } from 'react'
|
import { useEffect, useRef, memo } from 'react'
|
||||||
import { cn } from '@/lib'
|
import { useMeQuery } from '@/apis'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
|
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 { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
|
||||||
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
||||||
|
|
||||||
|
const AI_LABEL = '아이마켓코리아 (구매 MD)'
|
||||||
|
|
||||||
export function ChatMessage() {
|
export function ChatMessage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 w-full min-h-0 pr-[16px]">
|
<div className="flex-1 w-full min-h-0">
|
||||||
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
|
{/* 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 />
|
<ChatList />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -34,7 +36,7 @@ function ChatList() {
|
|||||||
if (!chats || chats.length === 0) {
|
if (!chats || chats.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -42,7 +44,7 @@ function ChatList() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
{chats.map((message, index) => (
|
{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 />}
|
{isLoading && <TypingBubble />}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
@ -50,14 +52,15 @@ function ChatList() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터.
|
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터(AI 말풍선 톤).
|
||||||
function TypingBubble() {
|
function TypingBubble() {
|
||||||
return (
|
return (
|
||||||
<div className="mb-[56px] pt-[36px]" aria-label="협상 중" role="status">
|
<div className="mb-6 flex flex-col items-start gap-1.5" aria-label="협상 중" role="status">
|
||||||
<div className="inline-flex items-center gap-[6px]">
|
<span className="text-xs font-bold text-neutral-80">{AI_LABEL}</span>
|
||||||
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.3s]" />
|
<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-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.15s]" />
|
<span className="size-2 rounded-full bg-brand-500 animate-bounce [animation-delay:-0.3s]" />
|
||||||
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -65,46 +68,43 @@ function TypingBubble() {
|
|||||||
|
|
||||||
const MessageItem = memo(function MessageItem({
|
const MessageItem = memo(function MessageItem({
|
||||||
message,
|
message,
|
||||||
isFirst,
|
|
||||||
messages,
|
messages,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
}: {
|
}: {
|
||||||
message: ChatMessageType
|
message: ChatMessageType
|
||||||
isFirst: boolean
|
|
||||||
messages: ChatMessageType[]
|
messages: ChatMessageType[]
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
}) {
|
}) {
|
||||||
const isBot = message.sender === 'bot'
|
const isBot = message.sender === 'bot'
|
||||||
|
|
||||||
// 직전이 reject 폼이면 사용자 답변은 숨기고 구분선만 표시
|
// 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음)
|
||||||
if (!isBot && currentIndex > 0) {
|
if (!isBot && currentIndex > 0) {
|
||||||
const prev = messages[currentIndex - 1]
|
const prev = messages[currentIndex - 1]
|
||||||
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
||||||
return (
|
return null
|
||||||
<>
|
|
||||||
<div className="text-right mb-[56px]" />
|
|
||||||
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 showIndicator = useChatInitStore((s) => s.wasInProgress)
|
||||||
|
const hasScript = Boolean(message.script && message.script.trim())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-[56px]">
|
<div className="mb-5 animate-fade-in">
|
||||||
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
|
<div className="flex flex-col items-start gap-1.5">
|
||||||
{/* 봇 스크립트의 `**굵게**` 경량 마크업 해석 (표현 규칙은 프론트 소유 — emphasis.tsx) */}
|
<span className="text-xs font-bold text-neutral-80">{AI_LABEL}</span>
|
||||||
<div className="body-1-read-r">{renderEmphasis(message.script || '')}</div>
|
{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>
|
||||||
|
|
||||||
<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 && (
|
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
|
||||||
<Indicator number={message.indicator_value} />
|
<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 UserMessage = memo(function UserMessage({ text }: { text: string }) {
|
||||||
|
const { data: user } = useMeQuery()
|
||||||
|
const label = `${user?.supplierName ?? '공급사'} (나)`
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="mb-5 flex flex-col items-end gap-1.5 animate-fade-in">
|
||||||
<div className="text-right mb-[56px]">
|
<span className="text-xs font-bold text-neutral-80">{label}</span>
|
||||||
<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="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}
|
{text}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import { UserButton } from '@/features/chat/components/UserButton'
|
|||||||
export function ChatSection() {
|
export function ChatSection() {
|
||||||
const { userButtonConfig } = useChatStore()
|
const { userButtonConfig } = useChatStore()
|
||||||
return (
|
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 />
|
<ChatMessage />
|
||||||
<UserButton {...userButtonConfig} />
|
{/* 하단 액션 덱 */}
|
||||||
|
<div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
||||||
|
<UserButton {...userButtonConfig} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,34 +8,33 @@ export function ItemImage() {
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
aria-label="이미지 크게 보기"
|
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} />
|
{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">
|
<img
|
||||||
<span className="text-neutral-00 text-base font-medium">이미지 크게 보기</span>
|
src={item_image}
|
||||||
</div>
|
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>
|
</button>
|
||||||
{isOpen && <ImageModal src={item_image} onClose={() => setIsOpen(false)} />}
|
{isOpen && <ImageModal src={item_image} onClose={() => setIsOpen(false)} />}
|
||||||
</div>
|
</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 }) {
|
function ImageModal({ src, onClose }: { src: string; onClose: () => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
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()}>
|
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||||
{src ? (
|
{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} />
|
<ImageIcon size={96} strokeWidth={1.25} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -63,9 +62,9 @@ function ImageModal({ src, onClose }: { src: string; onClose: () => void }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="닫기"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { cn } from '@/lib'
|
|
||||||
import { ItemImage } from '@/features/chat/components/ItemImage'
|
import { ItemImage } from '@/features/chat/components/ItemImage'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
import { formatLeadTime } from '@/features/chat/lib/format'
|
import { formatLeadTime } from '@/features/chat/lib/format'
|
||||||
@ -74,19 +73,18 @@ function ItemInfo() {
|
|||||||
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}원`
|
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}원`
|
||||||
const formattedPrice = `${priceText}(${item_vat_yn || 'VAT별도'})`
|
const formattedPrice = `${priceText}(${item_vat_yn || 'VAT별도'})`
|
||||||
|
|
||||||
const renderRow = (title: string, data: string, important = false) => {
|
const renderRow = (title: string, data: string) => {
|
||||||
const textClass = important ? 'title-3 text-neutral-90' : 'body-3 text-neutral-70'
|
|
||||||
const displayData = data || '-'
|
const displayData = data || '-'
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start self-stretch gap-1 min-w-0">
|
<div className="flex items-start justify-between gap-3 self-stretch border-b border-border/60 py-2 last:border-0 min-w-0">
|
||||||
<div className={cn(textClass, 'w-[100px]')}>{title}</div>
|
<span className="shrink-0 text-[13px] text-neutral-60">{title}</span>
|
||||||
<div
|
<span
|
||||||
className={cn(textClass, 'flex-1 min-w-0 break-words break-keep cursor-default')}
|
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)}
|
onMouseEnter={(e) => displayData !== '-' && showTooltip(e, displayData)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
{displayData}
|
{displayData}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -94,31 +92,35 @@ function ItemInfo() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full flex-1 overflow-hidden min-h-0">
|
<div className="flex flex-col w-full flex-1 overflow-hidden min-h-0">
|
||||||
<div
|
<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)}
|
onMouseEnter={(e) => item_name && showTooltip(e, item_name)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
{item_name}
|
{item_name}
|
||||||
</div>
|
</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">
|
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
|
||||||
{renderRow('상품코드', item_code, true)}
|
{renderRow('상품코드', item_code)}
|
||||||
{renderRow('단가', formattedPrice, true)}
|
{renderRow('단가', formattedPrice)}
|
||||||
{renderRow('모델명', item_model_name, true)}
|
{renderRow('모델명', item_model_name)}
|
||||||
{renderRow('제조사', item_maker_name)}
|
{renderRow('제조사', item_maker_name)}
|
||||||
{renderRow('최소주문수량', item_min_order_quantity)}
|
{renderRow('최소주문수량', item_min_order_quantity)}
|
||||||
{renderRow('리드타임', formatLeadTime(item_lead_time))}
|
{renderRow('리드타임', formatLeadTime(item_lead_time))}
|
||||||
|
|
||||||
<div className="flex items-start self-stretch gap-1 flex-[1_0]">
|
<div className="flex items-start justify-between gap-3 self-stretch py-2">
|
||||||
<div className="body-3 text-neutral-70 w-[100px]">규격</div>
|
<span className="shrink-0 text-sm text-neutral-60">규격</span>
|
||||||
<div
|
<span
|
||||||
className="body-3 text-neutral-70 self-stretch w-0 flex-[1_0] overflow-hidden whitespace-normal break-words break-keep cursor-default"
|
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)}
|
onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
{item_spec || '-'}
|
{item_spec || '-'}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { useState, useEffect } from 'react'
|
||||||
|
import { Clock } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib'
|
||||||
import { getTimeRemaining } from '@/features/chat/lib/remainingTime'
|
import { getTimeRemaining } from '@/features/chat/lib/remainingTime'
|
||||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||||
|
|
||||||
// 헤더의 협상 잔여 시간. 1초마다 tick 을 올려 렌더 중 남은 시간을 다시 계산한다.
|
// 헤더의 협상 잔여 시간 칩. 1초마다 tick 을 올려 렌더 중 남은 시간을 다시 계산한다.
|
||||||
export function RemainingTime() {
|
export function RemainingTime() {
|
||||||
const { quotation_end_time } = useChatInitStore()
|
const { quotation_end_time } = useChatInitStore()
|
||||||
const [, setTick] = useState(0)
|
const [, setTick] = useState(0)
|
||||||
@ -13,6 +15,17 @@ export function RemainingTime() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const remaining = quotation_end_time ? getTimeRemaining(quotation_end_time) : '-'
|
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 { useNavigate } from 'react-router'
|
||||||
import { List } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
import { Percent, Price } from '@/features/chat/components/userInputs'
|
import { Percent, Price } from '@/features/chat/components/userInputs'
|
||||||
@ -8,37 +7,28 @@ import type { UserButtonConfig } from '@/features/chat/types'
|
|||||||
|
|
||||||
// max-[1180px]: 채팅 폭이 좁아지면 버튼 패딩·최소폭 축소, 상품목록은 아이콘만 남긴다
|
// max-[1180px]: 채팅 폭이 좁아지면 버튼 패딩·최소폭 축소, 상품목록은 아이콘만 남긴다
|
||||||
const style = {
|
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:
|
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]',
|
'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-[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]',
|
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:
|
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) {
|
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
|
||||||
if (type === '') return null
|
if (type === '') return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// pr-[16px]: 채팅 스크롤 영역(ChatMessage 바깥 pr-[16px])과 오른쪽 끝 정렬
|
<div className="flex w-full justify-center overflow-x-auto px-6 py-4 max-[1180px]:px-4">
|
||||||
<div className="flex w-full pb-[52px] pr-[16px]">
|
<div className="flex justify-center">
|
||||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center w-full">
|
{type === 'one-black' && <OneBlack text={text || '확인'} />}
|
||||||
<div />
|
{type === 'one-gray' && <OneGray text={text || '확인'} />}
|
||||||
<div className="flex justify-center">
|
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
|
||||||
{type === 'one-black' && <OneBlack text={text || '확인'} />}
|
{type === 'percent' && <Percent />}
|
||||||
{type === 'one-gray' && <OneGray text={text || '확인'} />}
|
{type === 'three-black' && (
|
||||||
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
|
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
|
||||||
{type === 'percent' && <Percent />}
|
)}
|
||||||
{type === 'three-black' && (
|
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
|
||||||
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
|
{type === 'loading' && <LoadingDots />}
|
||||||
)}
|
|
||||||
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
|
|
||||||
{type === 'loading' && <LoadingDots />}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<GoToList />
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 }) {
|
function OneBlack({ text }: { text: string }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage)
|
const sendMessage = useChatStore((s) => s.sendMessage)
|
||||||
|
|||||||
@ -7,22 +7,27 @@ export function Contact() {
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full pl-[24px] pb-[24px]">
|
<section className="w-full pt-4">
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="border-b border-border pb-2">
|
||||||
<div className="title-5 text-neutral-80">헬프 데스크</div>
|
<span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 pt-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
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>
|
</button>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-0.5 text-sm text-neutral-60">
|
||||||
<div className="body-5 text-neutral-60">010-0000-0000</div>
|
<span>010-0000-0000</span>
|
||||||
<div className="body-5 text-neutral-60">o2odev@o2o.kr</div>
|
<span>o2odev@o2o.kr</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
|
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { interactive, cn } from '@/lib'
|
|
||||||
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
|
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
|
||||||
|
|
||||||
// 유의사항 및 이용방법 — 클릭 시 안내 팝업을 연다.
|
// 유의사항 및 이용방법 — 클릭 시 안내 팝업을 연다.
|
||||||
@ -12,13 +11,12 @@ export function Guide() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={cn(
|
className="flex w-full items-center justify-between border-b border-border pb-2 text-left transition-colors hover:border-neutral-40"
|
||||||
'flex w-full bg-white rounded-[28px] py-[20px] pl-[24px] pr-[16px] justify-between items-center text-left',
|
|
||||||
interactive,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="menu-title text-neutral-80 break-keep">유의사항 및 이용방법</span>
|
<span className="text-[11px] font-bold tracking-wide text-neutral-60 break-keep">
|
||||||
<ChevronRight size={20} className="text-neutral-70" />
|
유의사항 및 이용방법
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} className="text-neutral-50" />
|
||||||
</button>
|
</button>
|
||||||
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
|
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -22,39 +22,33 @@ export function MDInformation({ isOpen, setIsOpen }: Props) {
|
|||||||
if (memoArray.length === 0) return null
|
if (memoArray.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full min-w-[200px]">
|
<section className="w-full">
|
||||||
<div className="bg-white rounded-[28px]">
|
<button
|
||||||
<div className={cn('flex items-center w-full h-[4.75rem] px-4 pl-6', isOpen ? 'pt-6 pb-3' : 'py-6')}>
|
type="button"
|
||||||
<div className="flex items-center flex-1 justify-between">
|
aria-expanded={isOpen}
|
||||||
<div className="menu-title text-neutral-80">MD 안내사항</div>
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
<button
|
className="flex w-full items-center justify-between border-b border-border pb-2"
|
||||||
type="button"
|
>
|
||||||
aria-expanded={isOpen}
|
<span className="text-[11px] font-bold tracking-wide text-neutral-60">MD 안내사항</span>
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
<ChevronDown
|
||||||
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
|
size={16}
|
||||||
>
|
className={cn('text-neutral-50 transition-transform duration-300', !isOpen && '-rotate-90')}
|
||||||
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{isOpen && (
|
||||||
className={cn(
|
<div className="max-h-[220px] animate-fade-in overflow-y-auto pt-3">
|
||||||
'flex flex-col items-start gap-[19px] w-full px-6 overflow-hidden transition-all duration-300 ease-in-out',
|
<div className="flex flex-col gap-2 break-keep">
|
||||||
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')}>
|
|
||||||
{memoArray.map((item, i) => (
|
{memoArray.map((item, i) => (
|
||||||
<div key={i} className="flex items-start gap-2 body-3 text-neutral-70">
|
<div key={i} className="flex items-start gap-2 text-sm text-neutral-70">
|
||||||
<span className="flex-shrink-0">•</span>
|
<span className="shrink-0 text-neutral-50">•</span>
|
||||||
<div className="flex-1 whitespace-pre-wrap">{linkText(item)}</div>
|
<div className="flex-1 whitespace-pre-wrap">{linkText(item)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +57,7 @@ function linkText(text: string) {
|
|||||||
const isUrl = (s: string) => /^https?:\/\/[^\s]+$/.test(s)
|
const isUrl = (s: string) => /^https?:\/\/[^\s]+$/.test(s)
|
||||||
const parts = text.split(urlRegex)
|
const parts = text.split(urlRegex)
|
||||||
return (
|
return (
|
||||||
<div className="body-3 text-neutral-70">
|
<div className="text-sm text-neutral-70">
|
||||||
{parts.map((part, i) =>
|
{parts.map((part, i) =>
|
||||||
isUrl(part) ? (
|
isUrl(part) ? (
|
||||||
<a
|
<a
|
||||||
@ -71,7 +65,7 @@ function linkText(text: string) {
|
|||||||
href={part}
|
href={part}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
</a>
|
||||||
|
|||||||
@ -1,16 +1,24 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { cn } from '@/lib'
|
||||||
import { MDInformation } from '@/features/chat/components/menu/MDInformation'
|
import { MDInformation } from '@/features/chat/components/menu/MDInformation'
|
||||||
import { NegoStep } from '@/features/chat/components/menu/NegoStep'
|
import { NegoStep } from '@/features/chat/components/menu/NegoStep'
|
||||||
import { Guide } from '@/features/chat/components/menu/Guide'
|
import { Guide } from '@/features/chat/components/menu/Guide'
|
||||||
import { Contact } from '@/features/chat/components/menu/Contact'
|
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 [isMDOpen, setIsMDOpen] = useState(true)
|
||||||
const [isStepOpen, setIsStepOpen] = 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 (
|
return (
|
||||||
<div className="flex flex-col w-[360px] max-[1350px]:w-[320px] max-[1180px]:w-[280px] h-full pt-[64px] pr-[24px]">
|
<div className={cn(wrap)}>
|
||||||
<div className="flex flex-col flex-1 gap-[28px]">
|
<div className="flex flex-1 flex-col gap-6">
|
||||||
<MDInformation isOpen={isMDOpen} setIsOpen={setIsMDOpen} />
|
<MDInformation isOpen={isMDOpen} setIsOpen={setIsMDOpen} />
|
||||||
<NegoStep isOpen={isStepOpen} setIsOpen={setIsStepOpen} />
|
<NegoStep isOpen={isStepOpen} setIsOpen={setIsStepOpen} />
|
||||||
<Guide />
|
<Guide />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ChevronDown } from 'lucide-react'
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
|
|
||||||
@ -7,46 +7,78 @@ interface Props {
|
|||||||
setIsOpen: (isOpen: boolean) => void
|
setIsOpen: (isOpen: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = ['서비스안내', '담당자확인', '협상품목안내', '가격협상', '협상종료']
|
const STEPS = [
|
||||||
|
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
|
||||||
|
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
|
||||||
|
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
|
||||||
|
{ name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' },
|
||||||
|
{ name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' },
|
||||||
|
]
|
||||||
|
|
||||||
export function NegoStep({ isOpen, setIsOpen }: Props) {
|
export function NegoStep({ isOpen, setIsOpen }: Props) {
|
||||||
const chats = useChatStore((s) => s.messages)
|
const chats = useChatStore((s) => s.messages)
|
||||||
const currentStep = chats[chats.length - 1]?.display_step || '서비스안내'
|
const currentStep = chats[chats.length - 1]?.display_step || '서비스안내'
|
||||||
|
const currentIndex = Math.max(
|
||||||
|
0,
|
||||||
|
STEPS.findIndex((s) => s.name === currentStep),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full min-w-[200px]">
|
<section className="w-full">
|
||||||
<div className="bg-white rounded-[28px]">
|
<button
|
||||||
<div className={cn('flex items-center w-full h-[4.75rem] px-4 pl-6', isOpen ? 'pt-6 pb-3' : 'py-6')}>
|
type="button"
|
||||||
<div className="flex items-center flex-1 justify-between">
|
aria-expanded={isOpen}
|
||||||
<div className="menu-title text-neutral-80">협상절차</div>
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
<button
|
className="flex w-full items-center justify-between border-b border-border pb-2"
|
||||||
type="button"
|
>
|
||||||
aria-expanded={isOpen}
|
<span className="text-[11px] font-bold tracking-wide text-neutral-60">협상절차</span>
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
<ChevronDown
|
||||||
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
|
size={16}
|
||||||
>
|
className={cn('text-neutral-50 transition-transform duration-300', !isOpen && '-rotate-90')}
|
||||||
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{isOpen && (
|
||||||
className={cn(
|
<div className="animate-fade-in pt-4">
|
||||||
'flex flex-col items-start gap-[19px] w-full px-6 overflow-hidden transition-all duration-300 ease-in-out',
|
<div className="relative">
|
||||||
isOpen ? 'max-h-[200px] pb-8 opacity-100' : 'max-h-0 pb-0 opacity-0',
|
{/* 세로 레일 */}
|
||||||
)}
|
<span className="absolute left-[9px] top-3 bottom-3 w-0.5 bg-neutral-20" />
|
||||||
>
|
{STEPS.map((step, i) => {
|
||||||
<div className="flex flex-col gap-2 items-start self-stretch break-keep">
|
const done = i < currentIndex
|
||||||
{STEPS.map((step, i) => (
|
const current = i === currentIndex
|
||||||
<div key={step} className="flex items-start gap-2">
|
return (
|
||||||
<div className={cn('body-3 text-neutral-80', currentStep === step && 'font-bold')}>
|
<div key={step.name} className="relative flex items-start gap-3 py-1.5">
|
||||||
{i + 1}. {step}
|
<div
|
||||||
|
className={cn(
|
||||||
|
'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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function GuideContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -7,19 +7,19 @@ import { GuideContent, GuideContactBox } from './GuideContent'
|
|||||||
export function ServiceGuidePopup({ onClose }: { onClose: () => void }) {
|
export function ServiceGuidePopup({ onClose }: { onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="닫기"
|
aria-label="닫기"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
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,
|
interactive,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X size={20} className="text-neutral-80" />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex w-full flex-col gap-6 px-[40px]">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<GuideContent />
|
<GuideContent />
|
||||||
<GuideContactBox />
|
<GuideContactBox />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,8 +10,8 @@ interface ServiceInfoPopupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buttonStyle = cn(
|
const buttonStyle = cn(
|
||||||
'flex h-[50px] w-[168px] items-center justify-center rounded-full',
|
'flex h-[46px] flex-1 sm:flex-none sm:w-[168px] items-center justify-center rounded-xl',
|
||||||
'border border-neutral-30 bg-white title-5 text-neutral-70',
|
'border border-border bg-white text-sm font-bold text-neutral-70 hover:bg-neutral-10',
|
||||||
interactive,
|
interactive,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,13 +20,13 @@ export function ServiceInfoPopup({ isOpen, onNeverShowAgain, onCloseToday }: Ser
|
|||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.40)] p-4">
|
<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-[1000px] flex-col overflow-y-auto rounded-[24px] bg-white px-[80px] pb-[48px] pt-[56px]">
|
<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">
|
<div className="mb-6 flex w-full flex-col gap-5">
|
||||||
<GuideContent />
|
<GuideContent />
|
||||||
<GuideContactBox />
|
<GuideContactBox />
|
||||||
</div>
|
</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 type="button" className={buttonStyle} onClick={onNeverShowAgain}>
|
||||||
안내 보지 않기
|
안내 보지 않기
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||||
|
|
||||||
interface BidSummaryProps {
|
interface BidSummaryProps {
|
||||||
@ -8,54 +9,42 @@ interface BidSummaryProps {
|
|||||||
isVAT: boolean
|
isVAT: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 투찰 결과 요약 카드
|
// 투찰 결과 요약 카드 (보고서식 final-summary)
|
||||||
export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }: BidSummaryProps) {
|
export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }: BidSummaryProps) {
|
||||||
|
const priceStr = (bidPrice || 0).toLocaleString()
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-4">
|
<div className="w-full rounded-2xl border-2 border-brand-light bg-white p-5 shadow-md">
|
||||||
<h1 className="headline-3 text-neutral-90">투찰 결과 요약</h1>
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<BidItem title="상품명" value={itemName || '-'} />
|
<CheckCircle2 className="size-5 text-success" />
|
||||||
<BidItem title="상품 코드" value={itemCode || '-'} />
|
<h3 className="text-sm font-bold text-neutral-90">투찰 결과 요약</h3>
|
||||||
<BidPrice title="투찰 가격" number={bidPrice || 0} isVAT={isVAT} />
|
|
||||||
<BidItem title="배송형태" value={deliveryType || '-'} />
|
|
||||||
</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>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BidPrice({ number, isVAT, title }: { number: number; isVAT: boolean; title: string }) {
|
<div className="divide-y divide-border/60 rounded-xl border border-border">
|
||||||
const numberString = number.toLocaleString()
|
<Row label="상품명" value={itemName || '-'} />
|
||||||
return (
|
<Row label="상품 코드" value={itemCode || '-'} mono />
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
<Dot />
|
<span className="shrink-0 text-sm text-neutral-60">투찰 가격</span>
|
||||||
<div className="flex flex-1">
|
<span className="min-w-0 flex-1 break-keep text-right text-sm">
|
||||||
<div className="body-1-read-b flex flex-shrink-0">{title} :</div>
|
<span className="font-bold text-[#E5484D]">{priceStr}원</span>
|
||||||
<div className="flex flex-1 flex-wrap">
|
<span className="text-neutral-70"> ({numberToKorean(bidPrice || 0)}원)</span>
|
||||||
<span className="body-1-read-b text-negative break-keep"> {numberString}원</span>
|
<span className="text-neutral-70"> {isVAT ? 'VAT포함' : 'VAT별도'}</span>
|
||||||
<span className="body-1-read-b text-neutral-90 break-keep">
|
|
||||||
({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원)
|
|
||||||
</span>
|
</span>
|
||||||
<span className="body-1-read-b text-neutral-90 break-keep"> {isVAT ? 'VAT포함' : 'VAT별도'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Row label="배송형태" value={deliveryType || '-'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dot() {
|
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end items-center w-[26px] h-[30px]">
|
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||||
<p className="body-1-read-r">•</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,78 +1,31 @@
|
|||||||
import { cn } from '@/lib'
|
// 협상 성공률 카드 (0~100%). 10칸 컬러 블록바 — 구간별 색(빨강→주황→초록)으로 채운다.
|
||||||
|
|
||||||
// 협상 성공률 바 (0~100%). 구간별 색: 빨강(~30) / 주황(~70) / 초록(70~)
|
|
||||||
function selectBgColor(index: number, currentStep: number): string {
|
|
||||||
let fill = '#E71C3B'
|
|
||||||
if (currentStep > 3 && currentStep <= 7) fill = '#FDB21C'
|
|
||||||
if (currentStep > 7) fill = '#16BD14'
|
|
||||||
return index <= currentStep ? fill : '#ddd'
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnitBlock({ blockIndex, percent }: { blockIndex: number; percent: number }) {
|
|
||||||
const minBlockPercent = blockIndex * 10
|
|
||||||
const maxBlockPercent = (blockIndex + 1) * 10
|
|
||||||
const remindCount = percent - minBlockPercent
|
|
||||||
|
|
||||||
let nodeType: 'fill' | 'empty' | 'half' = 'empty'
|
|
||||||
if (maxBlockPercent < percent) nodeType = 'fill'
|
|
||||||
else if (minBlockPercent <= percent) nodeType = 'half'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{percent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Indicator({ number }: { number: number }) {
|
export function Indicator({ number }: { number: number }) {
|
||||||
const percent = Math.min(Math.max(number, 0), 100)
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-3 bg-neutral-00 rounded-[28px] px-10 py-[36px]">
|
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
|
||||||
<h1 className="title-1 text-neutral-90">협상 성공률</h1>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-2">
|
<h3 className="text-sm font-bold text-neutral-90">협상 성공률</h3>
|
||||||
{percent === 0 ? (
|
<span className="font-mono text-base font-extrabold" style={{ color }}>
|
||||||
<div className="relative h-6">
|
{percent}%
|
||||||
<div className="absolute left-[20px] -translate-x-1/2 text-xl text-black font-bold">▼ 0%</div>
|
</span>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-6" />
|
|
||||||
)}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
|
||||||
<UnitBlock key={i} blockIndex={i} percent={percent} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-1">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-3.5 flex-1 rounded-[3px] transition-colors duration-300"
|
||||||
|
style={{ backgroundColor: i < filled ? color : 'var(--neutral-20)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-neutral-60">
|
||||||
|
협상이 진행될수록 성공률이 실시간으로 갱신됩니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,19 +39,20 @@ export function OtherReason({
|
|||||||
const isTextareaDisabled = disabled || !isChecked
|
const isTextareaDisabled = disabled || !isChecked
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2.5">
|
||||||
<label className={cn('flex items-start gap-2 flex-shrink-0', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
<label className={cn('flex shrink-0 items-start gap-2.5', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
||||||
<input
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'appearance-none w-[16px] h-[16px] mt-[10px] flex-shrink-0 rounded-full border-[1.5px]',
|
'mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
isChecked ? 'border-brand-600' : isError ? 'border-negative' : 'border-neutral-40',
|
||||||
isChecked
|
disabled && 'opacity-60',
|
||||||
? 'border-neutral-70 bg-[radial-gradient(circle,var(--neutral-70)_40%,white_40%)]'
|
|
||||||
: isError
|
|
||||||
? 'border-negative bg-white'
|
|
||||||
: 'border-neutral-60 bg-white',
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{isChecked && <span className="size-2.5 rounded-full bg-brand-600" />}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="rejectrsp"
|
name="rejectrsp"
|
||||||
value="기타"
|
value="기타"
|
||||||
@ -59,15 +60,15 @@ export function OtherReason({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<div className={cn('reject break-keep whitespace-nowrap mt-[4px] flex', disabled && 'text-neutral-60')}>
|
<span className={cn('text-sm text-neutral-80', disabled && 'text-neutral-50')}>기타</span>
|
||||||
기타
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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',
|
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||||
isTextareaDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
|
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',
|
errorMessage && 'border-negative',
|
||||||
)}
|
)}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
@ -75,10 +76,10 @@ export function OtherReason({
|
|||||||
placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요."
|
placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요."
|
||||||
disabled={isTextareaDisabled}
|
disabled={isTextareaDisabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
style={{ minHeight: '35px', lineHeight: '20px' }}
|
style={{ minHeight: '38px', lineHeight: '20px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||||
import { findRestoreScript, extractPart } from '@/features/chat/lib/rejectForm'
|
import { findRestoreScript, extractPart } from '@/features/chat/lib/rejectForm'
|
||||||
@ -51,44 +52,47 @@ export function RejectCM() {
|
|||||||
const isDisabled = isLoading || isSubmitted
|
const isDisabled = isLoading || isSubmitted
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-2 transition-all duration-300">
|
<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 h-[48px] bg-[#CD2A2D] headline-3 text-neutral-00 rounded-[12px] items-center justify-center">
|
<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>
|
||||||
|
|
||||||
<div className="reject break-keep py-2">
|
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
|
||||||
최종 공급 희망 가격과 배송 형태를 입력하여 주시기 바랍니다.
|
최종 공급 희망 가격과 배송 형태를 입력하여 주시기 바랍니다.
|
||||||
<br />
|
<br />
|
||||||
가격 검토를 통해 경쟁력 있는 가격일 경우 다시 협상에 참여할 기회가 주어지오니 신중한 가격 제안 부탁드립니다.
|
가격 검토를 통해 경쟁력 있는 가격일 경우 다시 협상에 참여할 기회가 주어지오니 신중한 가격 제안 부탁드립니다.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col w-full gap-1">
|
<div className="flex flex-col w-full gap-1">
|
||||||
<div className="flex w-full items-start gap-4">
|
<div className="flex w-full items-start gap-3">
|
||||||
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]">공급 희망 가격</div>
|
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
|
||||||
<div className="flex flex-wrap w-full items-center pt-2">
|
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={cn(
|
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',
|
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||||
isDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
|
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() : ''}
|
value={price ? parseInt(price).toLocaleString() : ''}
|
||||||
onChange={(e) => handlePriceChange(e.target.value)}
|
onChange={(e) => handlePriceChange(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
disabled={isDisabled}
|
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>
|
||||||
<div className="reject-gray whitespace-nowrap">[{koreanPrice} 원]</div>
|
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex w-full gap-4">
|
<div className="flex w-full gap-3">
|
||||||
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]">배송 형태</div>
|
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">배송 형태</div>
|
||||||
<div className="flex w-full items-center justify-between pr-20 flex-wrap">
|
<div className="flex w-full flex-wrap items-center gap-x-6 gap-y-2">
|
||||||
{['협력사배송', '지정택배배송', '픽업배송'].map((opt) => (
|
{['협력사배송', '지정택배배송', '픽업배송'].map((opt) => (
|
||||||
<SelectRadio
|
<SelectRadio
|
||||||
key={opt}
|
key={opt}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||||
@ -80,12 +81,13 @@ export function RejectRSP() {
|
|||||||
const isDisabled = isLoading || isSubmitted
|
const isDisabled = isLoading || isSubmitted
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-2 transition-all duration-300">
|
<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 h-[48px] bg-[#CD2A2D] headline-3 text-neutral-00 rounded-[12px] items-center justify-center">
|
<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>
|
||||||
|
|
||||||
<div className="reject break-keep py-2">
|
<div className="py-2 text-sm leading-relaxed text-neutral-70 break-keep">
|
||||||
협상이 합의에 도달하지 못하였습니다. 최종 희망하는 가격을 입력해주시기 바랍니다.
|
협상이 합의에 도달하지 못하였습니다. 최종 희망하는 가격을 입력해주시기 바랍니다.
|
||||||
<br />
|
<br />
|
||||||
추가로 제시한 가격을 수용할 수 없는 이유를 선택하여 주시기 바라며, 목록에 없는 경우 기타 항목 선택 후 간단한 사유를
|
추가로 제시한 가격을 수용할 수 없는 이유를 선택하여 주시기 바라며, 목록에 없는 경우 기타 항목 선택 후 간단한 사유를
|
||||||
@ -93,31 +95,33 @@ export function RejectRSP() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col w-full gap-1">
|
<div className="flex flex-col w-full gap-1">
|
||||||
<div className="flex w-full items-start gap-4">
|
<div className="flex w-full items-start gap-3">
|
||||||
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]">공급 희망 가격</div>
|
<div className="w-[100px] shrink-0 pt-2.5 text-sm font-bold text-neutral-90">공급 희망 가격</div>
|
||||||
<div className="flex flex-wrap w-full items-center pt-2">
|
<div className="flex w-full flex-wrap items-center gap-2 pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={cn(
|
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',
|
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||||
isDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
|
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() : ''}
|
value={price ? parseInt(price).toLocaleString() : ''}
|
||||||
onChange={(e) => handlePriceChange(e.target.value)}
|
onChange={(e) => handlePriceChange(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
disabled={isDisabled}
|
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>
|
||||||
<div className="reject-gray whitespace-nowrap">[{koreanPrice} 원]</div>
|
<div className="whitespace-nowrap text-sm text-neutral-50">[{koreanPrice} 원]</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex w-full gap-4">
|
<div className="flex w-full gap-3">
|
||||||
<div className="flex reject-2 whitespace-nowrap pt-2 pb-2 flex-shrink-0 w-[110px]">합의 불가 사유</div>
|
<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">
|
<div className="flex w-full flex-col mt-2 gap-2">
|
||||||
{REASONS.map((r) => (
|
{REASONS.map((r) => (
|
||||||
<SelectRadio
|
<SelectRadio
|
||||||
|
|||||||
@ -1,49 +1,82 @@
|
|||||||
|
import { CheckCircle2 } from 'lucide-react'
|
||||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||||
import { formatLeadTime } from '@/features/chat/lib/format'
|
import { formatLeadTime } from '@/features/chat/lib/format'
|
||||||
import type { ChatSummary } from '@/features/chat/types'
|
import type { ChatSummary } from '@/features/chat/types'
|
||||||
|
|
||||||
// 협상 결과 요약 카드
|
// 협상 결과 요약 카드
|
||||||
export function Summary({ data }: { data: ChatSummary }) {
|
export function Summary({ data }: { data: ChatSummary }) {
|
||||||
|
const priceStr = (data.final_price || 0).toLocaleString()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-4">
|
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
|
||||||
<h1 className="headline-3 text-neutral-90">협상 결과 요약</h1>
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<div className="body-1-read-r">
|
<CheckCircle2 className="size-5 text-success" />
|
||||||
협상이 완료되어 아래와 같이 결과를 요약하오니 다시 한 번 최종 확인 부탁 드립니다. 최종 확인 후 협상 결과에 대한
|
<h3 className="text-sm font-bold text-neutral-90">협상 결과 요약</h3>
|
||||||
수정 변경은 불가함을 안내 드립니다.
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="body-1-read-r">협상 개시 시간 : {formatKoreanDateTime(data.nego_start_date)}</div>
|
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
|
||||||
<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 || '-'}){' '}
|
</p>
|
||||||
{data.supplier_manager_email || '-'}
|
|
||||||
|
{/* 개요 */}
|
||||||
|
<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="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>
|
||||||
<div className="body-1-read-r">협상 상세 내역</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<DetailText title="상품 코드" value={data.item_code || '-'} />
|
{/* 계약 / 담당 MD */}
|
||||||
<DetailText title="상품 명" value={data.item_name || '-'} />
|
<div className="mt-3 space-y-1 text-xs text-neutral-70">
|
||||||
<DetailText title="모델 명" value={data.item_model || '-'} />
|
<p>공급 계약 기간: 협상 완료일로부터 1년 ({addOneYear(data.nego_end_date)})까지</p>
|
||||||
<DetailText title="제품 규격" value={data.item_spec || '-'} />
|
<p>
|
||||||
<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: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
|
담당 MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
|
||||||
</div>
|
</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>
|
</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 {
|
function kstParts(iso: string): Record<string, string> | null {
|
||||||
if (!iso) return null
|
if (!iso) return null
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
@ -63,55 +96,16 @@ function kstParts(iso: string): Record<string, string> | null {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// "YYYY년 MM월 dd일 HH시 MM분 SS초" (KST)
|
// "YYYY년 MM월 dd일 HH시 MM분" (KST)
|
||||||
function formatKoreanDateTime(iso: string): string {
|
function formatKoreanDateTime(iso: string): string {
|
||||||
const p = kstParts(iso)
|
const p = kstParts(iso)
|
||||||
if (!p) return '-'
|
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 {
|
function addOneYear(iso: string): string {
|
||||||
const p = kstParts(iso)
|
const p = kstParts(iso)
|
||||||
if (!p) return '-'
|
if (!p) return '-'
|
||||||
return `${parseInt(p.year) + 1}년 ${p.month}월 ${p.day}일`
|
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
|
const isChecked = selectedValue === value
|
||||||
return (
|
return (
|
||||||
<label className={cn('flex items-center gap-2', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
<label className={cn('flex items-start gap-2.5', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
||||||
<input
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'appearance-none w-[16px] h-[16px] min-w-[16px] min-h-[16px] rounded-full border-[1.5px] whitespace-nowrap',
|
'mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
isChecked ? 'border-brand-600' : errorMessage ? 'border-negative' : 'border-neutral-40',
|
||||||
isChecked
|
disabled && 'opacity-60',
|
||||||
? 'border-neutral-70 bg-[radial-gradient(circle,var(--neutral-70)_40%,white_40%)]'
|
|
||||||
: errorMessage
|
|
||||||
? 'border-negative bg-white'
|
|
||||||
: 'border-neutral-60 bg-white',
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{isChecked && <span className="size-2.5 rounded-full bg-brand-600" />}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
type="radio"
|
type="radio"
|
||||||
name={name}
|
name={name}
|
||||||
value={value}
|
value={value}
|
||||||
@ -40,7 +41,9 @@ export function SelectRadio({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
disabled={disabled}
|
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>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -59,15 +62,15 @@ export function SubmitButton({
|
|||||||
if (isSubmitted) return null
|
if (isSubmitted) return null
|
||||||
|
|
||||||
const style = isLoading
|
const style = isLoading
|
||||||
? 'bg-neutral-40 text-neutral-80 cursor-not-allowed'
|
? 'bg-neutral-20 text-neutral-60 cursor-not-allowed'
|
||||||
: isValid
|
: isValid
|
||||||
? 'bg-neutral-90 text-white cursor-pointer hover:bg-neutral-80'
|
? 'bg-brand-600 text-white shadow-sm cursor-pointer hover:bg-brand-700 active:scale-[0.98]'
|
||||||
: 'bg-neutral-20 text-neutral-60 cursor-not-allowed'
|
: 'bg-neutral-20 text-neutral-50 cursor-not-allowed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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,
|
style,
|
||||||
)}
|
)}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function InputWithUnit({
|
|||||||
const errorId = useId()
|
const errorId = useId()
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-3">
|
<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
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@ -32,27 +32,27 @@ function InputWithUnit({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
|
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}
|
placeholder={placeholder}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
aria-describedby={error ? errorId : undefined}
|
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>
|
<span className="opacity-0">{value}</span>
|
||||||
{value && <span className="ml-1">{unit}</span>}
|
{value && <span className="ml-1">{unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
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="전송"
|
aria-label="전송"
|
||||||
>
|
>
|
||||||
전송
|
전송
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</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 { buildPageItems } from '@/features/list/lib/pagination'
|
||||||
import { ArrowIcon, DotsIcon } from '@/features/list/components/paginationIcons'
|
|
||||||
|
|
||||||
interface PaginationProps {
|
interface PaginationProps {
|
||||||
totalPages: number
|
totalPages: number
|
||||||
@ -9,82 +9,41 @@ interface PaginationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) {
|
export function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) {
|
||||||
if (totalPages === 0) {
|
if (totalPages <= 1) return null
|
||||||
return <div className="w-full my-[35px] h-[50px]" />
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = buildPageItems(currentPage, totalPages)
|
const items = buildPageItems(currentPage, totalPages)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full my-[35px]">
|
<div className="flex items-center justify-center gap-1.5 py-1">
|
||||||
<div className="flex w-full h-[50px] items-center justify-center gap-3">
|
<NavButton dir="prev" enabled={currentPage > 1} onClick={() => onPageChange(currentPage - 1)} />
|
||||||
<NavButton
|
{items.map((item, idx) =>
|
||||||
dir="prev"
|
item === 'dots' ? (
|
||||||
enabled={currentPage > 1}
|
<span key={`dots-${idx}`} className="px-1 text-sm text-neutral-50 select-none">
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
…
|
||||||
/>
|
</span>
|
||||||
|
) : (
|
||||||
<div className="flex items-center justify-center h-full rounded-[999px] border border-neutral-30 bg-neutral-00">
|
<button
|
||||||
<div className="flex items-center justify-center gap-1 h-[38px]">
|
key={item}
|
||||||
{items.map((item, idx) =>
|
type="button"
|
||||||
item === 'dots' ? (
|
onClick={item === currentPage ? undefined : () => onPageChange(item)}
|
||||||
<DotsIcon key={`dots-${idx}`} />
|
className={cn(
|
||||||
) : (
|
'flex size-9 items-center justify-center rounded-lg text-sm font-semibold tabular-nums transition-all',
|
||||||
<PageNumber
|
item === currentPage
|
||||||
key={item}
|
? 'bg-brand-600 text-white shadow-sm cursor-default'
|
||||||
page={item}
|
: 'text-neutral-70 hover:bg-neutral-10 active:scale-[0.96]',
|
||||||
active={item === currentPage}
|
|
||||||
onClick={() => onPageChange(item)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
</div>
|
{item}
|
||||||
|
</button>
|
||||||
<NavButton
|
),
|
||||||
dir="next"
|
)}
|
||||||
enabled={currentPage < totalPages}
|
<NavButton dir="next" enabled={currentPage < totalPages} onClick={() => onPageChange(currentPage + 1)} />
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageNumber({
|
function NavButton({ dir, enabled, onClick }: { dir: 'prev' | 'next'; enabled: boolean; onClick: () => void }) {
|
||||||
page,
|
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
page: number
|
|
||||||
active: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={active ? undefined : onClick}
|
|
||||||
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),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavButton({
|
|
||||||
dir,
|
|
||||||
enabled,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
dir: 'prev' | 'next'
|
|
||||||
enabled: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -92,11 +51,11 @@ function NavButton({
|
|||||||
disabled={!enabled}
|
disabled={!enabled}
|
||||||
onClick={enabled ? onClick : undefined}
|
onClick={enabled ? onClick : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center w-[50px] h-[50px] rounded-full border border-neutral-30 bg-neutral-00 select-none',
|
'flex size-9 items-center justify-center rounded-lg border border-border transition-all',
|
||||||
enabled ? cn('text-neutral-60', interactive) : 'text-neutral-40 cursor-default',
|
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>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
import { Modal } from '@/components'
|
import { Modal } from '@/components'
|
||||||
import { cn, interactive } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
|
|
||||||
const REASONS = ['단종', '품절', '기타'] as const
|
const REASONS = ['단종', '품절', '기타'] as const
|
||||||
|
|
||||||
@ -36,88 +37,87 @@ export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isSubmitDisabled || !selectedReason) return
|
if (isSubmitDisabled || !selectedReason) return
|
||||||
|
onSubmit(isEtcOpen ? customReason.trim() : selectedReason)
|
||||||
const reason = isEtcOpen ? customReason.trim() : selectedReason
|
|
||||||
onSubmit(reason)
|
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<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">
|
<div className="flex items-center justify-between border-b border-border p-5">
|
||||||
거부 사유를 입력해주세요
|
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
{/* 사유 선택 */}
|
onClick={onClose}
|
||||||
<div className={cn('flex gap-5 transition-all duration-300 ease-out', isEtcOpen ? 'mt-0' : 'mt-12')}>
|
aria-label="닫기"
|
||||||
{REASONS.map((reason) => (
|
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||||
<button
|
>
|
||||||
key={reason}
|
<X className="size-4" />
|
||||||
type="button"
|
</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',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{reason}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기타 입력 + 에러 */}
|
{/* 본문 */}
|
||||||
<div className="flex flex-col items-center">
|
<div className="space-y-4 p-5">
|
||||||
<div
|
<div className="grid grid-cols-3 gap-2">
|
||||||
className={cn(
|
{REASONS.map((reason) => (
|
||||||
'flex w-[400px] overflow-hidden transition-all duration-300 ease-out',
|
<button
|
||||||
isEtcOpen ? 'visible h-[120px] opacity-100' : 'invisible h-0 opacity-0',
|
key={reason}
|
||||||
)}
|
type="button"
|
||||||
>
|
onClick={() => handleReasonClick(reason)}
|
||||||
<textarea
|
className={cn(
|
||||||
placeholder="사유를 입력하여 주십시오"
|
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98]',
|
||||||
value={customReason}
|
selectedReason === reason
|
||||||
onChange={(e) => {
|
? 'border-brand-600 bg-brand-light text-brand-600'
|
||||||
setCustomReason(e.target.value)
|
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
|
||||||
setShowError(false)
|
)}
|
||||||
}}
|
>
|
||||||
className={cn(
|
{reason}
|
||||||
'h-full w-full resize-none rounded-lg bg-background p-2.5 body-3 text-foreground',
|
</button>
|
||||||
'placeholder:text-muted-foreground focus:outline-none',
|
))}
|
||||||
'transition-colors duration-200',
|
|
||||||
showError ? 'border-2 border-destructive' : 'border border-foreground',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 에러 메시지 (항상 공간 확보) */}
|
{isEtcOpen && (
|
||||||
<div
|
<div className="animate-fade-in">
|
||||||
className={cn(
|
<textarea
|
||||||
'w-[400px] overflow-hidden transition-all duration-300 ease-out',
|
placeholder="사유를 입력하여 주십시오"
|
||||||
showError && isEtcOpen ? 'mt-2 h-[24px] opacity-100' : 'h-0 opacity-0',
|
value={customReason}
|
||||||
)}
|
onChange={(e) => {
|
||||||
>
|
setCustomReason(e.target.value)
|
||||||
<p className="text-left body-5 text-destructive">기타 사유를 입력해주세요</p>
|
setShowError(false)
|
||||||
</div>
|
}}
|
||||||
</div>
|
rows={3}
|
||||||
|
className={cn(
|
||||||
{/* 전송 */}
|
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
|
||||||
<button
|
'placeholder:text-neutral-50 focus:ring-1',
|
||||||
type="button"
|
showError
|
||||||
onClick={handleSubmit}
|
? 'border-destructive focus:border-destructive focus:ring-destructive'
|
||||||
disabled={isSubmitDisabled}
|
: 'border-border focus:border-brand-600 focus:ring-brand-600',
|
||||||
className={cn(
|
)}
|
||||||
'flex h-[50px] w-full max-w-[130px] items-center justify-center rounded-full px-8 title-2',
|
/>
|
||||||
'bg-foreground text-background',
|
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
|
||||||
interactive,
|
</div>
|
||||||
isSubmitDisabled && 'cursor-not-allowed opacity-40',
|
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
전송
|
|
||||||
</button>
|
{/* 푸터 */}
|
||||||
|
<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="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>
|
</div>
|
||||||
</Modal>
|
</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 { useSessionListQuery } from '@/apis'
|
||||||
import { useListStore } from '@/features/list/stores/useListStore'
|
import { useListStore } from '@/features/list/stores/useListStore'
|
||||||
import {
|
import { deadlineToOrder, statusLabelToCode, toListItem, typeLabelToCode } from '@/features/list/lib/adapter'
|
||||||
deadlineToOrder,
|
|
||||||
statusLabelToCode,
|
|
||||||
toListItem,
|
|
||||||
typeLabelToCode,
|
|
||||||
} from '@/features/list/lib/adapter'
|
|
||||||
import type { ListItem } from '@/features/list/types'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
||||||
export function useList() {
|
export function useList() {
|
||||||
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
|
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
|
||||||
useListStore()
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const query = useSessionListQuery({
|
const query = useSessionListQuery({
|
||||||
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
||||||
@ -26,25 +18,7 @@ export function useList() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const items = useMemo(() => (query.data?.items ?? []).map(toListItem), [query.data])
|
const items = useMemo(() => (query.data?.items ?? []).map(toListItem), [query.data])
|
||||||
const total = query.data?.total ?? 0
|
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE))
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
||||||
|
|
||||||
const selectedItem = useMemo(
|
return { items, isLoading: query.isLoading, totalPages, currentPage, setCurrentPage }
|
||||||
() => 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 (페이지에서 쓰는 것만)
|
// 공개 API (페이지에서 쓰는 것만)
|
||||||
export { FilterContainer } from '@/features/list/containers/FilterContainer'
|
export { ListWorkspace } from '@/features/list/containers/ListWorkspace'
|
||||||
export { ContentContainer } from '@/features/list/containers/ContentContainer'
|
|
||||||
|
|||||||
@ -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
|
currentPage: number
|
||||||
|
|
||||||
toggleType: (item: string) => void
|
toggleType: (item: string) => void
|
||||||
|
setType: (item: string | null) => void
|
||||||
toggleStatus: (item: string) => void
|
toggleStatus: (item: string) => void
|
||||||
|
setStatus: (item: string | null) => void
|
||||||
toggleDeadline: (item: string) => void
|
toggleDeadline: (item: string) => void
|
||||||
setCurrentPage: (page: number) => void
|
setCurrentPage: (page: number) => void
|
||||||
resetFilters: () => void
|
resetFilters: () => void
|
||||||
@ -23,8 +25,10 @@ export const useListStore = create<ListState>((set) => ({
|
|||||||
|
|
||||||
toggleType: (item) =>
|
toggleType: (item) =>
|
||||||
set((s) => ({ selectedType: toggle(s.selectedType, item), currentPage: 1 })),
|
set((s) => ({ selectedType: toggle(s.selectedType, item), currentPage: 1 })),
|
||||||
|
setType: (item) => set({ selectedType: item, currentPage: 1 }),
|
||||||
toggleStatus: (item) =>
|
toggleStatus: (item) =>
|
||||||
set((s) => ({ selectedStatus: toggle(s.selectedStatus, item), currentPage: 1 })),
|
set((s) => ({ selectedStatus: toggle(s.selectedStatus, item), currentPage: 1 })),
|
||||||
|
setStatus: (item) => set({ selectedStatus: item, currentPage: 1 }),
|
||||||
toggleDeadline: (item) =>
|
toggleDeadline: (item) =>
|
||||||
set((s) => ({ selectedDeadline: toggle(s.selectedDeadline, item), currentPage: 1 })),
|
set((s) => ({ selectedDeadline: toggle(s.selectedDeadline, item), currentPage: 1 })),
|
||||||
setCurrentPage: (page) => set({ currentPage: page }),
|
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 url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.css');
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* 디자인 토큰 (light 전용) */
|
/* 디자인 토큰 (light 전용) — 토스풍(Toss) 팔레트 */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
--radius: 0.625rem; /* 10px 기준 */
|
--radius: 0.75rem; /* 12px 기준 (토스풍 라운드) */
|
||||||
|
|
||||||
/* 브랜드 */
|
/* 브랜드 (Toss blue) */
|
||||||
--brand-500: #5a83c1;
|
--brand-500: #6098f5; /* ring / 보조 */
|
||||||
--brand-600: #254d8b; /* = --primary */
|
--brand-600: #3182f6; /* = --primary */
|
||||||
--brand-700: #1b3965;
|
--brand-700: #1b64da; /* hover / pressed */
|
||||||
|
--brand-light: #e8f3ff; /* 연한 브랜드 배경 (선택행·AI 말풍선) */
|
||||||
|
|
||||||
/* Neutral */
|
/* Neutral (cool gray) */
|
||||||
--neutral-90: #151515;
|
--neutral-90: #191f28; /* text-primary */
|
||||||
--neutral-80: #303030;
|
--neutral-80: #333d4b;
|
||||||
--neutral-70: #606060;
|
--neutral-70: #4e5968; /* text-secondary */
|
||||||
--neutral-60: #808080;
|
--neutral-60: #8b95a1; /* text-caption */
|
||||||
--neutral-40: #dadbde;
|
--neutral-50: #b0b8c1; /* placeholder */
|
||||||
--neutral-30: #eaebef;
|
--neutral-40: #d1d6db;
|
||||||
--neutral-20: #f0f1f4;
|
--neutral-30: #e5e8eb; /* border-card */
|
||||||
--neutral-10: #f7f8fa;
|
--neutral-20: #f2f4f6; /* bg-gray */
|
||||||
|
--neutral-10: #f9fafb; /* bg-section */
|
||||||
--neutral-00: #ffffff;
|
--neutral-00: #ffffff;
|
||||||
|
|
||||||
/* 상태 / 테이블 */
|
/* 상태 / 테이블 */
|
||||||
--negative: #e71c3b;
|
--negative: #e71c3b;
|
||||||
--info: #4880ef;
|
--info: #3182f6;
|
||||||
--table-header: #e3e8f1;
|
--table-header: #f8f9fa;
|
||||||
--table-hover: #dbe6fc;
|
--table-hover: #f2f4f6;
|
||||||
--table-selected: #b6ccf9;
|
--table-selected: var(--brand-light);
|
||||||
|
|
||||||
/* 시맨틱 */
|
/* 시맨틱 */
|
||||||
--background: var(--neutral-00);
|
--background: var(--neutral-00);
|
||||||
--foreground: var(--neutral-90);
|
--foreground: var(--neutral-90);
|
||||||
--surface: #eef0f4; /* 중립 라이트 그레이 (콘텐츠 면, 블루기 최소) */
|
--surface: #f9fafb; /* 콘텐츠 면 (토스 섹션 배경) */
|
||||||
|
|
||||||
--card: var(--neutral-00);
|
--card: var(--neutral-00);
|
||||||
--card-foreground: var(--neutral-90);
|
--card-foreground: var(--neutral-90);
|
||||||
@ -55,7 +57,7 @@
|
|||||||
--ring: var(--brand-500);
|
--ring: var(--brand-500);
|
||||||
|
|
||||||
--destructive: var(--negative);
|
--destructive: var(--negative);
|
||||||
--success: #10b981;
|
--success: #0b8f57;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,12 +67,14 @@
|
|||||||
--color-brand-500: var(--brand-500);
|
--color-brand-500: var(--brand-500);
|
||||||
--color-brand-600: var(--brand-600);
|
--color-brand-600: var(--brand-600);
|
||||||
--color-brand-700: var(--brand-700);
|
--color-brand-700: var(--brand-700);
|
||||||
|
--color-brand-light: var(--brand-light);
|
||||||
|
|
||||||
/* Neutral */
|
/* Neutral */
|
||||||
--color-neutral-90: var(--neutral-90);
|
--color-neutral-90: var(--neutral-90);
|
||||||
--color-neutral-80: var(--neutral-80);
|
--color-neutral-80: var(--neutral-80);
|
||||||
--color-neutral-70: var(--neutral-70);
|
--color-neutral-70: var(--neutral-70);
|
||||||
--color-neutral-60: var(--neutral-60);
|
--color-neutral-60: var(--neutral-60);
|
||||||
|
--color-neutral-50: var(--neutral-50);
|
||||||
--color-neutral-40: var(--neutral-40);
|
--color-neutral-40: var(--neutral-40);
|
||||||
--color-neutral-30: var(--neutral-30);
|
--color-neutral-30: var(--neutral-30);
|
||||||
--color-neutral-20: var(--neutral-20);
|
--color-neutral-20: var(--neutral-20);
|
||||||
@ -120,6 +124,28 @@
|
|||||||
--radius-2xl: calc(var(--radius) * 1.8); /* 18px */
|
--radius-2xl: calc(var(--radius) * 1.8); /* 18px */
|
||||||
--radius-3xl: calc(var(--radius) * 2.2); /* 22px */
|
--radius-3xl: calc(var(--radius) * 2.2); /* 22px */
|
||||||
--radius-4xl: calc(var(--radius) * 2.6); /* 26px */
|
--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 {
|
@layer base {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { type ReactNode } from 'react'
|
|||||||
import { cn } from '@/lib'
|
import { cn } from '@/lib'
|
||||||
|
|
||||||
const HEADER_BASE =
|
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 = {
|
const HEADER_ALIGN = {
|
||||||
left: 'justify-start',
|
left: 'justify-start',
|
||||||
|
|||||||
@ -8,19 +8,19 @@ const SIDEBAR_WIDTH = {
|
|||||||
'w-[20%] max-w-[320px] ' +
|
'w-[20%] max-w-[320px] ' +
|
||||||
'max-[1520px]:w-[23%] max-[1410px]:w-[25%] ' +
|
'max-[1520px]:w-[23%] max-[1410px]:w-[25%] ' +
|
||||||
'max-[1180px]:w-[27%] max-[1024px]:w-[29%] max-[960px]:w-[33%]',
|
'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
|
} as const
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
root: 'flex w-full min-h-screen max-h-screen bg-background',
|
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',
|
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',
|
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:
|
logoHeader:
|
||||||
'flex w-full h-[56px] pl-[32px] pr-[24px] py-[8px] items-center justify-between shrink-0',
|
'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 pt-2 pr-2 pb-2 pl-0',
|
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',
|
||||||
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden rounded-b-[8px] bg-surface',
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type SidebarWidth = keyof typeof SIDEBAR_WIDTH
|
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 = {
|
const baseStyle = {
|
||||||
fontFamily: 'var(--font-sans)',
|
fontFamily: 'var(--font-sans)',
|
||||||
fontSize: '18px',
|
fontSize: '14px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: '-0.28px',
|
letterSpacing: '-0.28px',
|
||||||
borderRadius: '8px',
|
borderRadius: '12px',
|
||||||
padding: '18px 24px',
|
padding: '12px 16px',
|
||||||
backgroundColor: 'var(--neutral-00)',
|
backgroundColor: 'var(--neutral-00)',
|
||||||
color: 'var(--neutral-90)',
|
color: 'var(--neutral-90)',
|
||||||
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.08)',
|
||||||
minWidth: '440px',
|
width: 'auto',
|
||||||
|
maxWidth: '90vw',
|
||||||
wordBreak: 'keep-all' as const,
|
wordBreak: 'keep-all' as const,
|
||||||
overflowWrap: 'break-word' as const,
|
overflowWrap: 'break-word' as const,
|
||||||
whiteSpace: 'pre-wrap' as const,
|
whiteSpace: 'pre-wrap' as const,
|
||||||
|
|||||||
@ -1,44 +1,103 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
import { useNavigate, useSearchParams, Navigate } from 'react-router'
|
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 { cn, interactive } from '@/lib'
|
||||||
import { MainLayout, MainHeaderBar } from '@/layouts'
|
import { MainLayout, MainHeaderBar } from '@/layouts'
|
||||||
import { ChatContainer, ItemSection, RemainingTime } from '@/features/chat'
|
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'
|
import { SidebarFooter } from '@/features/auth'
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const sessionId = searchParams.get('session_id') ?? ''
|
const sessionId = searchParams.get('session_id') ?? ''
|
||||||
|
const drawer = useChatUiStore((s) => s.drawer)
|
||||||
|
const close = useChatUiStore((s) => s.close)
|
||||||
|
|
||||||
// session_id 없이 진입하면 목록으로 되돌린다.
|
// session_id 없이 진입하면 목록으로 되돌린다.
|
||||||
if (!sessionId) return <Navigate to="/list" replace />
|
if (!sessionId) return <Navigate to="/list" replace />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout
|
<>
|
||||||
sidebarWidth="chat"
|
<MainLayout
|
||||||
scrollX
|
sidebarWidth="chat"
|
||||||
logoAction={<ListNavButton />}
|
sidebar={<Sidebar />}
|
||||||
sidebar={<Sidebar />}
|
header={
|
||||||
header={
|
<MainHeaderBar className="px-4 sm:px-6 min-[1180px]:px-8">
|
||||||
<MainHeaderBar className="px-[140px] max-[1350px]:px-[80px] max-[1180px]:px-[48px]">
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
<RemainingTime />
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
</MainHeaderBar>
|
<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" />
|
||||||
<ChatContainer sessionId={sessionId} />
|
<span className="hidden truncate text-sm font-bold text-neutral-90 lg:inline">
|
||||||
</MainLayout>
|
실시간 구매 협상 채널
|
||||||
|
</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()
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="목록으로 이동"
|
|
||||||
onClick={() => navigate('/list')}
|
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>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,14 @@
|
|||||||
import { MainLayout, MainHeaderBar } from '@/layouts'
|
import { PortalHeader } from '@/layouts/PortalHeader'
|
||||||
import { FilterContainer, ContentContainer } from '@/features/list'
|
import { ListWorkspace } from '@/features/list'
|
||||||
import { SidebarFooter } from '@/features/auth'
|
|
||||||
|
|
||||||
export function ListPage() {
|
export function ListPage() {
|
||||||
return (
|
return (
|
||||||
<MainLayout
|
<div className="flex min-h-svh flex-col bg-surface">
|
||||||
sidebarWidth="list"
|
<PortalHeader />
|
||||||
sidebar={<Sidebar />}
|
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">
|
||||||
header={
|
<ListWorkspace />
|
||||||
<MainHeaderBar align="center" className="px-[81px]">
|
</main>
|
||||||
<span className="text-xl font-bold whitespace-nowrap">견적 선택</span>
|
</div>
|
||||||
</MainHeaderBar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ContentContainer />
|
|
||||||
</MainLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sidebar() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FilterContainer />
|
|
||||||
<SidebarFooter />
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,16 +10,28 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-svh w-full items-center justify-center px-6">
|
<main className="flex min-h-svh w-full items-center justify-center bg-surface px-6">
|
||||||
<div className="flex w-full max-w-100 flex-col items-center gap-10">
|
<div className="flex w-full max-w-sm flex-col items-center gap-6">
|
||||||
<header className="flex flex-col items-center justify-center gap-4">
|
{/* 브랜드 락업 */}
|
||||||
<Logo size="md" />
|
<div className="flex flex-col items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold leading-[30px] tracking-[-0.48px]">
|
<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>
|
</h1>
|
||||||
</header>
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
<LoginForm />
|
{/* 푸터 */}
|
||||||
|
<p className="text-xs font-medium text-neutral-60">
|
||||||
|
문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user