Merge pull request '프론트엔드: 구조 정비 + 협상 채팅 페이지 (mock UI)' (#2) from feature/chat into main

Reviewed-on: Negosium/o2o-negosium#2
This commit is contained in:
minheon 2026-06-17 05:43:28 +00:00
commit 7e2d5e5978
85 changed files with 7235 additions and 1 deletions

11
front/.env.sample Normal file
View File

@ -0,0 +1,11 @@
# 환경 변수 템플릿 (git 에 커밋되는 유일한 env 파일)
# 새 환경을 세팅할 때 이 파일을 복사해서 .env.local / .env.dev / .env.prod 를 만든다.
#
# 주의: 클라이언트(브라우저) 번들에 노출되는 변수는 반드시 VITE_ 접두사를 붙여야 한다.
# 접두사 없는 변수는 빌드에 포함되지 않는다 (서버 비밀값은 여기 두지 말 것).
# 현재 실행 환경 식별용 (local | dev | prod)
VITE_APP_ENV=local
# 백엔드 API 베이스 URL (negosium 백엔드 기본 포트 9300)
VITE_API_BASE_URL=http://localhost:9300

29
front/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# env (실제 env 파일은 무시하고 .env.sample 만 커밋)
.env
.env.*
!.env.sample
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1 +1,66 @@
마지막 내 도리는 하자 .
# Negosium Front
React + TypeScript + Vite 기반 프론트엔드.
## 기술 스택
- **빌드/런타임**: Vite 8, React 19, TypeScript 6
- **상태/데이터**: TanStack Query(서버 상태), Zustand(클라이언트 상태)
- **HTTP**: axios
- **에디터**: Slate (`slate` / `slate-react` / `slate-history`)
- **스타일**: Tailwind CSS v4 (`@tailwindcss/vite`)
- **경로 alias**: `@/*``src/*`
## 요구 사항
- Node.js `20.19+` 또는 `22.12+` (Vite 8 요구 사항, 개발은 24.x 기준)
- npm
## 로컬 세팅
```bash
# 1. 프로젝트로 이동
cd front
# 2. 의존성 설치
npm install
# 3. env 파일 준비 (.env.sample 복사 후 값 채우기)
cp .env.sample .env.local
# 4. 로컬 개발 서버 실행 (기본 http://localhost:5173)
npm run dev
```
> 백엔드 API 는 negosium 백엔드(기본 `http://localhost:9300`)를 바라본다. `.env.local``VITE_API_BASE_URL` 로 조정한다.
## 환경 변수 (.env)
Vite 의 mode 기능으로 환경별 env 파일을 매핑한다. 클라이언트 번들에 노출되는 변수는 반드시 `VITE_` 접두사를 붙인다.
| 파일 | 사용 시점 | git |
| --- | --- | --- |
| `.env.sample` | 템플릿 (키 목록 공유용) | ✅ 커밋 |
| `.env.local` | 로컬 개발 (`npm run dev`, Vite 가 자동 로드) | 🚫 ignore |
| `.env.dev` | `--mode dev` (개발 서버) | 🚫 ignore |
| `.env.prod` | `--mode prod` (운영) | 🚫 ignore |
새 환경 세팅 시 `.env.sample` 을 복사해 환경별 파일을 만들고 값을 채운다. 코드에서는 `import.meta.env.VITE_API_BASE_URL` 처럼 접근한다.
> 참고: Vite 8 부터 `local` 은 모드 이름으로 사용할 수 없다(`.env.local` 의 `.local` 접미사와 충돌). 그래서 로컬 개발은 별도 모드 없이 기본 `vite`(mode: `development`)로 실행하고, 로컬 값은 `.env.local` 에 둔다 — Vite 는 `.env.local` 을 **모든 모드에서 항상 자동 로드**한다(개인 로컬 오버라이드 용도). 우선순위는 `.env.[mode]` > `.env.local` > `.env` 이므로, 각 env 파일에 **동일한 키 집합**을 유지하면 mode 파일 값이 항상 이긴다. 특정 mode 에만 있어야 할 키를 `.env.local` 에 두면 다른 mode 로 새어 들어갈 수 있으니 주의.
## 실행 / 빌드
`--mode <name>` 으로 `.env.[name]` 파일이 선택된다(로컬은 모드 없이 `.env.local` 자동 로드).
```bash
npm run dev # 로컬 개발 서버 (mode: development → .env.local 자동 로드)
npm run dev:dev # 개발 서버를 dev 환경 변수로 실행 (mode: dev → .env.dev)
npm run build # 운영 빌드 (mode: prod → .env.prod), build:prod 와 동일
npm run build:dev # 개발 빌드 (mode: dev → .env.dev)
npm run build:prod # 운영 빌드 (mode: prod → .env.prod)
npm run preview # 직전 빌드 결과 로컬 미리보기
npm run lint # ESLint
```

22
front/eslint.config.js Normal file
View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
front/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>temp-vite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3614
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
front/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "negosium-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:dev": "vite --mode dev",
"build": "tsc -b && vite build --mode prod",
"build:dev": "tsc -b && vite build --mode dev",
"build:prod": "tsc -b && vite build --mode prod",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@tanstack/react-query": "^5.101.0",
"axios": "^1.18.0",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.17.0",
"slate": "^0.124.1",
"slate-history": "^0.113.1",
"slate-react": "^0.124.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tailwindcss": "^4.3.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

1
front/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
front/public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

16
front/src/App.tsx Normal file
View File

@ -0,0 +1,16 @@
import { createBrowserRouter, RouterProvider } from 'react-router'
import LoginPage from '@/pages/LoginPage'
import ListPage from '@/pages/ListPage'
import ChatPage from '@/pages/ChatPage'
const router = createBrowserRouter([
{ path: '/', element: <LoginPage /> },
{ path: '/list', element: <ListPage /> },
{ path: '/chat', element: <ChatPage /> },
])
function App() {
return <RouterProvider router={router} />
}
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,52 @@
import { type ComponentProps } from 'react'
import { cn, interactive } from '@/lib'
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'outline'
| 'ghost'
| 'destructive'
export type ButtonSize = 'sm' | 'md' | 'lg'
const base =
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium select-none ' +
interactive +
' focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' +
'disabled:pointer-events-none disabled:opacity-50'
const variantClass: Record<ButtonVariant, string> = {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
outline: 'border border-input bg-background',
// 투명 배경이라 brightness 무효 → bg 하이라이트
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-white',
}
const sizeClass: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-6 text-base',
}
export interface ButtonProps extends ComponentProps<'button'> {
variant?: ButtonVariant
size?: ButtonSize
}
export function Button({
variant = 'primary',
size = 'md',
type = 'button',
className,
...props
}: ButtonProps) {
return (
<button
type={type}
className={cn(base, variantClass[variant], sizeClass[size], className)}
{...props}
/>
)
}

View File

@ -0,0 +1,18 @@
import { type ComponentProps } from 'react'
import { cn } from '@/lib'
export function Input({ type = 'text', className, ...props }: ComponentProps<'input'>) {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
}

View File

@ -0,0 +1,53 @@
import { type ComponentProps } from 'react'
import { cn } from '@/lib'
import logoColor from '@/assets/imarketkorea-logo.png'
import logoWhite from '@/assets/imarketkorea-logo-white.png'
export type LogoVariant = 'color' | 'white'
export type LogoSize = 'sm' | 'md' | 'lg'
const sources: Record<LogoVariant, string> = {
color: logoColor,
white: logoWhite,
}
const sizes: Record<LogoSize, { img: string; text: string; gap: string }> = {
sm: { img: 'h-6', text: 'text-base', gap: 'gap-1.5' },
md: { img: 'h-8', text: 'text-xl', gap: 'gap-2' },
lg: { img: 'h-10', text: 'text-2xl', gap: 'gap-2.5' },
}
export interface LogoProps extends Omit<ComponentProps<'div'>, 'children'> {
variant?: LogoVariant
size?: LogoSize
withText?: boolean
alt?: string
}
export function Logo({
variant = 'color',
size = 'md',
withText = true,
alt = 'iMarket Korea',
className,
...props
}: LogoProps) {
const s = sizes[size]
return (
<div className={cn('flex items-center', s.gap, className)} {...props}>
<img
src={sources[variant]}
alt={withText ? '' : alt}
className={cn('w-auto select-none', s.img)}
/>
{withText && (
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
iMarket Korea
</span>
)}
</div>
)
}
export default Logo

View File

@ -0,0 +1,5 @@
export { Button } from '@/components/Button'
export type { ButtonProps, ButtonVariant, ButtonSize } from '@/components/Button'
export { Input } from '@/components/Input'
export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo'

View File

@ -0,0 +1,16 @@
import { type ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분
retry: 1,
refetchOnWindowFocus: false,
},
},
})
export function Provider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

View File

@ -0,0 +1,60 @@
import { Button, Input } from '@/components'
import { useLogin } from '@/features/auth/hooks/useLogin'
const inputClassName = 'h-12 rounded-md px-4 text-base'
export function LoginForm() {
const {
id,
password,
errorMessage,
isLoading,
onIdChange,
onPasswordChange,
onLogin,
} = useLogin()
return (
<form
onSubmit={(e) => {
e.preventDefault()
onLogin()
}}
className="flex w-full flex-col justify-center gap-6"
>
<div className="flex flex-col gap-3">
<Input
type="text"
aria-label="아이디"
placeholder="아이디를 입력해주세요."
value={id}
onChange={(e) => onIdChange(e.target.value)}
autoComplete="username"
className={inputClassName}
/>
<Input
type="password"
aria-label="비밀번호"
placeholder="비밀번호를 입력해주세요."
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
autoComplete="current-password"
className={inputClassName}
/>
<p
className="text-sm tracking-[-0.28px] text-destructive"
role="alert"
aria-live="polite"
>
{errorMessage || ' '}
</p>
</div>
<Button type="submit" size="lg" className="w-full" disabled={isLoading}>
{isLoading ? '로그인 중…' : '로그인'}
</Button>
</form>
)
}
export default LoginForm

View File

@ -0,0 +1,21 @@
import { Button } from '@/components'
// 사이드바 하단: 공급사명 + 로그아웃
export function SidebarFooter() {
return (
<div className="flex w-full h-[60px] py-3 px-8 gap-3 items-center">
<div className="flex-1 flex items-center min-w-0 text-sm text-foreground">
{/* TODO: 공급사명 (auth store 연동) */}
<span className="truncate">-</span>
</div>
<Button
variant="secondary"
size="sm"
className="w-[61px] px-2 rounded-[4px] text-xs flex-shrink-0"
// TODO: 로그아웃 mutation 연동
>
</Button>
</div>
)
}

View File

@ -0,0 +1,55 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { useLoginMutation } from '@/features/auth/hooks/useLoginMutation'
export function useLogin() {
const navigate = useNavigate()
const loginMutation = useLoginMutation()
const [id, setId] = useState('')
const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const onIdChange = (value: string) => {
setId(value)
if (errorMessage) setErrorMessage('')
}
const onPasswordChange = (value: string) => {
setPassword(value)
if (errorMessage) setErrorMessage('')
}
const onLogin = () => {
if (!id || !password) {
setErrorMessage('아이디와 비밀번호를 입력해주세요.')
return
}
loginMutation.mutate(
{ id, password },
{
onSuccess: () => {
setErrorMessage('')
navigate('/list')
},
onError: (error) => {
setErrorMessage(
error instanceof Error
? error.message
: '아이디 또는 비밀번호가 올바르지 않습니다.',
)
},
},
)
}
return {
id,
password,
errorMessage,
isLoading: loginMutation.isPending,
onIdChange,
onPasswordChange,
onLogin,
}
}

View File

@ -0,0 +1,13 @@
import { useMutation } from '@tanstack/react-query'
export interface LoginParams {
id: string
password: string
}
// 임시 stub (검증만 통과하면 성공). TODO: 로그인 API 연동
export function useLoginMutation() {
return useMutation<void, Error, LoginParams>({
mutationFn: async () => {},
})
}

View File

@ -0,0 +1,2 @@
export { LoginForm } from '@/features/auth/components/LoginForm'
export { SidebarFooter } from '@/features/auth/components/SidebarFooter'

View File

@ -0,0 +1,115 @@
import { useEffect, useRef, memo } from 'react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary'
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
export function ChatMessage() {
return (
<div className="flex-1 w-full min-h-0 pr-[16px] pt-[64px]">
<div className="chat-scroll h-full overflow-y-auto flex flex-col pl-[140px] pr-[126px]">
<ChatList />
</div>
</div>
)
}
function ChatList() {
const bottomRef = useRef<HTMLDivElement | null>(null)
const chats = useChatStore((s) => s.messages)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chats])
if (!chats || chats.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="body-1 text-neutral-70"> .</p>
</div>
)
}
return (
<div className="flex flex-col w-full">
{chats.map((message, index) => (
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
))}
<div ref={bottomRef} />
</div>
)
}
const MessageItem = memo(function MessageItem({
message,
isFirst,
messages,
currentIndex,
}: {
message: ChatMessageType
isFirst: boolean
messages: ChatMessageType[]
currentIndex: number
}) {
const isBot = message.sender === 'bot'
// 직전이 reject 폼이면 사용자 답변은 숨기고 구분선만 표시
if (!isBot && currentIndex > 0) {
const prev = messages[currentIndex - 1]
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
return (
<>
<div className="text-right mb-[56px]" />
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
}
}
return <div>{isBot ? <BotMessage message={message} isFirst={isFirst} /> : <UserMessage text={message.script || ''} />}</div>
})
const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) {
return (
<div className="mb-[56px]">
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
<div className="body-1-read-r">{message.script || ''}</div>
</div>
<div className="flex flex-col gap-4 w-full mt-[32px]">
{message.bot_chat_type === 'indicator' && message.indicator_value != null && (
<Indicator number={message.indicator_value} />
)}
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}
{message.bot_chat_type === 'summaryCM' && message.summary && (
<BidSummary
itemName={message.summary.item_name}
itemCode={message.summary.item_code}
bidPrice={message.summary.final_price}
deliveryType={message.summary.delivery_type || ''}
isVAT={message.summary.item_isVAT}
/>
)}
{message.bot_chat_type === 'rejectRSP' && <RejectRSP />}
{message.bot_chat_type === 'rejectCM' && <RejectCM />}
</div>
</div>
)
})
const UserMessage = memo(function UserMessage({ text }: { text: string }) {
return (
<>
<div className="text-right mb-[56px]">
<div className="inline-block max-w-[87%] bg-neutral-40 text-neutral-90 text-lg leading-[150%] tracking-[-0.18px] px-[20px] py-[10px] rounded-full break-keep">
{text}
</div>
</div>
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
})

View File

@ -0,0 +1,13 @@
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { ChatMessage } from '@/features/chat/components/ChatMessage'
import { UserButton } from '@/features/chat/components/UserButton'
export function ChatSection() {
const { userButtonConfig } = useChatStore()
return (
<div className="flex flex-1 flex-col w-full h-full bg-surface rounded-bl-[8px]">
<ChatMessage />
<UserButton {...userButtonConfig} />
</div>
)
}

View File

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ImageIcon, X } from 'lucide-react'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
export function ItemImage() {
const { item_image } = useChatInitStore()
const [isOpen, setIsOpen] = useState(false)
return (
<div className="flex flex-col w-full items-center justify-center pt-16 pr-8 pb-8 pl-8">
<button
type="button"
onClick={() => setIsOpen(true)}
aria-label="이미지 크게 보기"
className="relative cursor-pointer group"
>
<Thumb src={item_image} />
<div className="absolute inset-0 flex items-center justify-center rounded-[16px] bg-neutral-40 opacity-0 transition-opacity duration-300 group-hover:opacity-90">
<span className="text-neutral-00 text-base font-medium"> </span>
</div>
</button>
{isOpen && <ImageModal src={item_image} onClose={() => setIsOpen(false)} />}
</div>
)
}
function Thumb({ src }: { src: string }) {
if (src) {
return <img src={src} alt="상품 이미지" className="rounded-[16px] w-[220px] h-[220px] object-cover" />
}
return (
<div className="flex w-[220px] h-[220px] items-center justify-center rounded-[16px] bg-neutral-20 text-neutral-60">
<ImageIcon size={48} strokeWidth={1.5} />
</div>
)
}
function ImageModal({ src, onClose }: { src: string; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="상품 이미지 확대"
>
<div className="relative" onClick={(e) => e.stopPropagation()}>
{src ? (
<img src={src} alt="상품 이미지" className="rounded-[16px] max-w-[42rem] max-h-[90vh] 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">
<ImageIcon size={96} strokeWidth={1.25} />
</div>
)}
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-white border border-neutral-30 text-neutral-80 cursor-pointer hover:bg-neutral-10 transition-colors"
>
<X size={20} />
</button>
</div>
</div>,
document.body,
)
}

View File

@ -0,0 +1,128 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib'
import { ItemImage } from '@/features/chat/components/ItemImage'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
export function ItemSection() {
return (
<div className="flex flex-col w-full flex-1 overflow-hidden min-h-0">
<ItemImage />
<ItemInfo />
</div>
)
}
interface TooltipState {
text: string
x: number
y: number
above?: boolean
}
function Tooltip({ text, x, y, above }: TooltipState) {
return createPortal(
<div
className="fixed z-[9999] pointer-events-none"
style={{ left: x, top: y, transform: above ? 'translateY(-100%)' : undefined }}
>
<div className="px-3 py-2 mb-1 rounded-lg bg-neutral-20 text-neutral-90 text-[11px] leading-[16px] whitespace-pre-wrap break-keep max-w-[240px] shadow-md border border-neutral-30">
{text}
</div>
</div>,
document.body,
)
}
function ItemInfo() {
const {
item_code,
item_price,
item_vat_yn,
item_model_name,
item_maker_name,
item_min_order_quantity,
item_lead_time,
item_spec,
item_name,
} = useChatInitStore()
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const showTooltip = useCallback((e: React.MouseEvent, text: string, above = false) => {
if (hideTimer.current) clearTimeout(hideTimer.current)
if (above) {
const rect = e.currentTarget.getBoundingClientRect()
setTooltip({ text, x: rect.left, y: rect.top - 8, above })
} else {
setTooltip({ text, x: e.clientX, y: e.clientY + 16, above })
}
}, [])
const hideTooltip = useCallback(() => {
hideTimer.current = setTimeout(() => setTooltip(null), 100)
}, [])
useEffect(() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current)
}, [])
const formatPrice = (price: number) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const isNewItem = !item_code && !item_price
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}`
const formattedPrice = `${priceText}(${item_vat_yn || 'VAT별도'})`
const renderRow = (title: string, data: string, important = false) => {
const textClass = important ? 'title-3 text-neutral-90' : 'body-3 text-neutral-70'
const displayData = data || '-'
return (
<div className="flex items-start self-stretch gap-1 min-w-0">
<div className={cn(textClass, 'w-[100px]')}>{title}</div>
<div
className={cn(textClass, 'w-[150px] break-keep cursor-default')}
onMouseEnter={(e) => displayData !== '-' && showTooltip(e, displayData)}
onMouseLeave={hideTooltip}
>
{displayData}
</div>
</div>
)
}
return (
<div className="flex flex-col w-full flex-1 overflow-hidden min-h-0">
<div
className="text-center headline-3 text-neutral-90 mb-4 truncate px-8 flex-shrink-0 cursor-default"
onMouseEnter={(e) => item_name && showTooltip(e, item_name)}
onMouseLeave={hideTooltip}
>
{item_name}
</div>
<div className="flex flex-1 items-start self-stretch mr-2 overflow-y-auto pr-6 pl-8 min-h-0">
<div className="flex flex-col items-start self-stretch flex-[1_0_auto] gap-2 min-w-0">
{renderRow('상품코드', item_code, true)}
{renderRow('단가', formattedPrice, true)}
{renderRow('모델명', item_model_name, true)}
{renderRow('제조사', item_maker_name)}
{renderRow('최소주문수량', item_min_order_quantity)}
{renderRow('리드타임', item_lead_time)}
<div className="flex items-start self-stretch gap-1 flex-[1_0]">
<div className="body-3 text-neutral-70 w-[100px]"></div>
<div
className="body-3 text-neutral-70 self-stretch w-0 flex-[1_0] overflow-hidden whitespace-normal break-words break-keep cursor-default"
onMouseEnter={(e) => item_spec && showTooltip(e, item_spec, true)}
onMouseLeave={hideTooltip}
>
{item_spec || '-'}
</div>
</div>
</div>
</div>
{tooltip && <Tooltip {...tooltip} />}
</div>
)
}

View File

@ -0,0 +1,18 @@
import { useState, useEffect } from 'react'
import { getTimeRemaining } from '@/features/chat/lib/remainingTime'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 헤더의 협상 잔여 시간. 1초마다 tick 을 올려 렌더 중 남은 시간을 다시 계산한다.
export function RemainingTime() {
const { quotation_end_time } = useChatInitStore()
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const remaining = quotation_end_time ? getTimeRemaining(quotation_end_time) : '-'
return <span className="title-1"> : {remaining}</span>
}

View File

@ -0,0 +1,99 @@
import { useNavigate } from 'react-router'
import { List, Loader2 } from 'lucide-react'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { Percent, Price } from '@/features/chat/components/userInputs'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import type { UserButtonConfig } from '@/features/chat/types'
const style = {
goToList:
'flex w-[159px] h-[48px] bg-neutral-40 hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-neutral-80 cursor-pointer gap-2 transition-all ease-out hover:scale-[1.01]',
black:
'flex min-w-[120px] px-[32px] h-[48px] bg-primary hover:brightness-[0.97] rounded-[999px] items-center justify-center title-2 text-primary-foreground cursor-pointer whitespace-nowrap transition-all duration-200 ease-out hover:scale-[1.01]',
gray: 'flex min-w-[120px] px-[32px] 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]',
white:
'flex min-w-[120px] px-[32px] 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]',
}
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
if (type === '') return null
return (
<div className="flex w-full pb-[52px]">
<div className="grid grid-cols-[1fr_auto_1fr] items-center w-full">
<div />
<div className="flex justify-center">
{type === 'one-black' && <OneBlack text={text || '확인'} />}
{type === 'one-gray' && <OneGray text={text || '확인'} />}
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
{type === 'percent' && <Percent />}
{type === 'three-black' && (
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
)}
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <Loader2 className="size-8 animate-spin text-neutral-60" />}
</div>
<div className="flex justify-end">
<GoToList />
</div>
</div>
</div>
)
}
function GoToList() {
const navigate = useNavigate()
return (
<button className={style.goToList} onClick={() => navigate('/list')} aria-label="상품 목록으로 이동">
<List size={24} />
<span> </span>
</button>
)
}
function OneBlack({ text }: { text: string }) {
const navigate = useNavigate()
const sendMessage = useChatStore((s) => s.sendMessage)
const onClick = () => (text === GO_TO_LIST_TEXT ? navigate('/list') : sendMessage(text))
return (
<button className={style.black} onClick={onClick}>
{text}
</button>
)
}
function OneGray({ text }: { text: string }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<button className={style.gray} onClick={() => sendMessage(text)}>
{text}
</button>
)
}
function ThreeBlack({ textList }: { textList: [string, string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<div className="flex gap-3">
{textList.map((t) => (
<button key={t} className={style.black} onClick={() => sendMessage(t)}>
{t}
</button>
))}
</div>
)
}
function BlackWhite({ textList }: { textList: [string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<div className="flex gap-3">
<button className={style.black} onClick={() => sendMessage(textList[0])}>
{textList[0]}
</button>
<button className={style.white} onClick={() => sendMessage(textList[1])}>
{textList[1]}
</button>
</div>
)
}

View File

@ -0,0 +1,22 @@
import { interactive, cn } from '@/lib'
// 헬프데스크 (TODO: 이용가이드 팝업 연동, 연락처는 추후 설정값으로 교체)
export function Contact() {
return (
<div className="flex flex-col w-full pl-[24px] pb-[24px]">
<div className="flex flex-col w-full gap-2">
<div className="title-5 text-neutral-80"> </div>
<button
type="button"
className={cn('body-5 text-neutral-80 bg-neutral-40 rounded-[8px] px-[12px] py-[6px] w-fit', interactive)}
>
</button>
<div className="flex flex-col gap-1">
<div className="body-5 text-neutral-60">-</div>
<div className="body-5 text-neutral-60">-</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import { ChevronRight } from 'lucide-react'
import { interactive, cn } from '@/lib'
// 유의사항 및 이용방법 (TODO: 이용가이드 팝업 연동)
export function Guide() {
return (
<button
type="button"
className={cn(
'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>
<ChevronRight size={20} className="text-neutral-70" />
</button>
)
}

View File

@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
interface Props {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}
export function MDInformation({ isOpen, setIsOpen }: Props) {
const { quotation_memo } = useChatInitStore()
const memoArray = useMemo(() => {
if (!quotation_memo?.trim()) return []
return quotation_memo
.split(/\r?\n+/)
.map((s) => s.trim())
.filter(Boolean)
}, [quotation_memo])
if (memoArray.length === 0) return null
return (
<div className="flex flex-col w-full min-w-[200px]">
<div className="bg-white rounded-[28px]">
<div className={cn('flex items-center w-full h-[4.75rem] px-4 pl-6', isOpen ? 'pt-6 pb-3' : 'py-6')}>
<div className="flex items-center flex-1 justify-between">
<div className="menu-title text-neutral-80">MD </div>
<button
type="button"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
>
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
</button>
</div>
</div>
<div
className={cn(
'flex flex-col items-start gap-[19px] w-full px-6 overflow-hidden transition-all duration-300 ease-in-out',
isOpen ? 'max-h-[200px] pb-8 opacity-100' : 'max-h-0 pb-0 opacity-0',
)}
>
<div className={cn('flex flex-col gap-2 items-start self-stretch max-h-[200px] break-keep', isOpen ? 'overflow-y-auto' : 'overflow-hidden')}>
{memoArray.map((item, i) => (
<div key={i} className="flex items-start gap-2 body-3 text-neutral-70">
<span className="flex-shrink-0"></span>
<div className="flex-1 whitespace-pre-wrap">{linkText(item)}</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
function linkText(text: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const isUrl = (s: string) => /^https?:\/\/[^\s]+$/.test(s)
const parts = text.split(urlRegex)
return (
<div className="body-3 text-neutral-70">
{parts.map((part, i) =>
isUrl(part) ? (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="block text-info underline cursor-pointer hover:brightness-95 transition-all"
>
</a>
) : (
<span key={i}>{i > 0 && isUrl(parts[i - 1]) ? part.replace(/^(\s)/, '') : part}</span>
),
)}
</div>
)
}

View File

@ -0,0 +1,21 @@
import { useState } from 'react'
import { MDInformation } from '@/features/chat/components/menu/MDInformation'
import { NegoStep } from '@/features/chat/components/menu/NegoStep'
import { Guide } from '@/features/chat/components/menu/Guide'
import { Contact } from '@/features/chat/components/menu/Contact'
export function MenuSection() {
const [isMDOpen, setIsMDOpen] = useState(true)
const [isStepOpen, setIsStepOpen] = useState(true)
return (
<div className="flex flex-col w-[360px] h-full pt-[64px] pr-[24px]">
<div className="flex flex-col flex-1 gap-[28px]">
<MDInformation isOpen={isMDOpen} setIsOpen={setIsMDOpen} />
<NegoStep isOpen={isStepOpen} setIsOpen={setIsStepOpen} />
<Guide />
</div>
<Contact />
</div>
)
}

View File

@ -0,0 +1,52 @@
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
interface Props {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}
const STEPS = ['서비스안내', '담당자확인', '협상품목안내', '가격협상', '협상종료']
export function NegoStep({ isOpen, setIsOpen }: Props) {
const chats = useChatStore((s) => s.messages)
const currentStep = chats[chats.length - 1]?.display_step || '서비스안내'
return (
<div className="flex flex-col w-full min-w-[200px]">
<div className="bg-white rounded-[28px]">
<div className={cn('flex items-center w-full h-[4.75rem] px-4 pl-6', isOpen ? 'pt-6 pb-3' : 'py-6')}>
<div className="flex items-center flex-1 justify-between">
<div className="menu-title text-neutral-80"></div>
<button
type="button"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-10 h-10 cursor-pointer text-neutral-70"
>
<ChevronDown size={20} className={cn('transition-transform duration-300', !isOpen && 'rotate-180')} />
</button>
</div>
</div>
<div
className={cn(
'flex flex-col items-start gap-[19px] w-full px-6 overflow-hidden transition-all duration-300 ease-in-out',
isOpen ? 'max-h-[200px] pb-8 opacity-100' : 'max-h-0 pb-0 opacity-0',
)}
>
<div className="flex flex-col gap-2 items-start self-stretch break-keep">
{STEPS.map((step, i) => (
<div key={step} className="flex items-start gap-2">
<div className={cn('body-3 text-neutral-80', currentStep === step && 'font-bold')}>
{i + 1}. {step}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,61 @@
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
interface BidSummaryProps {
itemName: string
itemCode: string
bidPrice: number
deliveryType: string
isVAT: boolean
}
// 투찰 결과 요약 카드
export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }: BidSummaryProps) {
return (
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-4">
<h1 className="headline-3 text-neutral-90"> </h1>
<BidItem title="상품명" value={itemName || '-'} />
<BidItem title="상품 코드" value={itemCode || '-'} />
<BidPrice title="투찰 가격" number={bidPrice || 0} isVAT={isVAT} />
<BidItem title="배송형태" value={deliveryType || '-'} />
</div>
)
}
function BidItem({ title, value }: { title: string; value: string }) {
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1 whitespace-pre-wrap">
<span className="body-1-read-b">{title} :</span>
<div className="flex body-1-read-r">&nbsp;{value}</div>
</div>
</div>
)
}
function BidPrice({ number, isVAT, title }: { number: number; isVAT: boolean; title: string }) {
const numberString = number.toLocaleString()
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1">
<div className="body-1-read-b flex flex-shrink-0">{title} :</div>
<div className="flex flex-1 flex-wrap">
<span className="body-1-read-b text-negative break-keep">&nbsp;{numberString}</span>
<span className="body-1-read-b text-neutral-90 break-keep">
&nbsp;({numberToKorean(parseInt(numberString.replace(/,/g, '')))})
</span>
<span className="body-1-read-b text-neutral-90 break-keep">&nbsp;{isVAT ? 'VAT포함' : 'VAT별도'}</span>
</div>
</div>
</div>
)
}
function Dot() {
return (
<div className="flex justify-end items-center w-[26px] h-[30px]">
<p className="body-1-read-r"></p>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { cn } from '@/lib'
// 협상 성공률 바 (0~100%). 구간별 색: 빨강(~30) / 주황(~70) / 초록(70~)
function selectBgColor(index: number, currentStep: number): string {
let fill = '#E71C3B'
if (currentStep > 3 && currentStep <= 7) fill = '#FDB21C'
if (currentStep > 7) fill = '#16BD14'
return index <= currentStep ? fill : '#ddd'
}
function UnitBlock({ blockIndex, percent }: { blockIndex: number; percent: number }) {
const minBlockPercent = blockIndex * 10
const maxBlockPercent = (blockIndex + 1) * 10
const remindCount = percent - minBlockPercent
let nodeType: 'fill' | 'empty' | 'half' = 'empty'
if (maxBlockPercent < percent) nodeType = 'fill'
else if (minBlockPercent <= percent) nodeType = 'half'
return (
<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 }) {
const percent = Math.min(Math.max(number, 0), 100)
return (
<div className="flex w-full flex-col gap-3 bg-neutral-00 rounded-[28px] px-10 py-[36px]">
<h1 className="title-1 text-neutral-90"> </h1>
<div className="flex flex-col gap-2">
{percent === 0 ? (
<div className="relative h-6">
<div className="absolute left-[20px] -translate-x-1/2 text-xl text-black font-bold">&nbsp;0%</div>
</div>
) : (
<div className="h-6" />
)}
<div className="flex gap-1">
{Array.from({ length: 10 }).map((_, i) => (
<UnitBlock key={i} blockIndex={i} percent={percent} />
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react'
import { cn } from '@/lib'
// RejectRSP 의 '기타' 사유: 라디오 + 자동 높이 textarea
export function OtherReason({
inputValue,
setInputValue,
errorMessage,
selectedValue,
onChange,
isError,
disabled,
}: {
inputValue: string
setInputValue: (value: string) => void
errorMessage: string
selectedValue: string
onChange: (value: string) => void
isError: boolean
disabled?: boolean
}) {
const isChecked = selectedValue === '기타'
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const ta = ref.current
if (!ta) return
ta.style.height = 'auto'
const maxHeight = 20 * 3 + 16
if (ta.scrollHeight > maxHeight) {
ta.style.height = `${maxHeight}px`
ta.style.overflowY = 'auto'
} else {
ta.style.height = `${ta.scrollHeight}px`
ta.style.overflowY = 'hidden'
}
}, [inputValue, isChecked])
const isTextareaDisabled = disabled || !isChecked
return (
<div className="flex flex-col gap-1">
<div className="flex items-start gap-2">
<label className={cn('flex items-start gap-2 flex-shrink-0', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
<input
className={cn(
'appearance-none w-[16px] h-[16px] mt-[10px] flex-shrink-0 rounded-full border-[1.5px]',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
isChecked
? 'border-neutral-70 bg-[radial-gradient(circle,var(--neutral-70)_40%,white_40%)]'
: isError
? 'border-negative bg-white'
: 'border-neutral-60 bg-white',
)}
type="radio"
name="rejectrsp"
value="기타"
checked={isChecked}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<div className={cn('reject break-keep whitespace-nowrap mt-[4px] flex', disabled && 'text-neutral-60')}>
</div>
</label>
<textarea
ref={ref}
className={cn(
'w-full px-3 py-2 body-3 resize-none border border-neutral-40 rounded-[8px] outline-none transition-all duration-200 placeholder:text-neutral-60 focus:border-neutral-70 focus:outline-none',
isTextareaDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
errorMessage && 'border-negative',
)}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요."
disabled={isTextareaDisabled}
rows={1}
style={{ minHeight: '35px', lineHeight: '20px' }}
/>
</div>
{errorMessage && <div className="body-5 text-negative pl-[65px]">{errorMessage}</div>}
</div>
)
}

View File

@ -0,0 +1,115 @@
import { useState, useMemo } from 'react'
import { cn } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { findRestoreScript, extractPart } from '@/features/chat/lib/rejectForm'
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
const MAX_PRICE = 999999999999999
// 재견적 실패 시: 최종 공급 희망 가격 + 배송 형태 입력 폼
export function RejectCM() {
const isLoading = useChatStore((s) => s.isLoading)
const sendMessage = useChatStore((s) => s.sendMessage)
const messages = useChatStore((s) => s.messages)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const isVAT = item_vat_yn === 'VAT별도'
// 폼 값은 이전 제출 내역에서 1회만 복원 (setter 미사용 = lazy 초기화 전용)
const restored = useState(() => findRestoreScript(messages, 'rejectCM'))[0]
const [price, setPrice] = useState(() => extractPart(restored, '공급희망가격-'))
const [selectedReason, setSelectedReason] = useState(() => extractPart(restored, '배송형태-'))
const [priceErrorMessage, setPriceErrorMessage] = useState('')
const [radioErrorMessage, setRadioErrorMessage] = useState('')
// 제출 완료 여부는 messages 변화에 반응해야 함 (제출 후 갱신 반영)
const isSubmitted = useMemo(() => Boolean(findRestoreScript(messages, 'rejectCM')), [messages])
const isValid = Boolean(price && parseInt(price) > 0) && Boolean(selectedReason)
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
if (numeric) setPriceErrorMessage('')
}
const koreanPrice = (() => {
if (!price) return '영'
const n = parseInt(price)
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
})()
const handleSubmit = () => {
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
setRadioErrorMessage(!selectedReason ? '배송 형태를 선택해주세요.' : '')
if (isValid && !isLoading && !isSubmitted) {
sendMessage(`공급희망가격-${price}, 배송형태-${selectedReason}`, 'text')
}
}
const isDisabled = isLoading || isSubmitted
return (
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-2 transition-all duration-300">
<div className="flex w-full h-[48px] bg-[#CD2A2D] headline-3 text-neutral-00 rounded-[12px] items-center justify-center">
</div>
<div className="reject break-keep py-2">
.
<br />
.
</div>
<div className="flex flex-col w-full gap-1">
<div className="flex w-full items-start gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]"> </div>
<div className="flex flex-wrap w-full items-center pt-2">
<div className="flex items-center gap-2">
<input
type="text"
className={cn(
'text-right max-w-[220px] h-full max-h-[35px] border p-2 body-3 placeholder:text-neutral-60 focus:border-neutral-70 rounded-[8px] outline-none border-neutral-40',
isDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isDisabled}
/>
<div className="reject whitespace-nowrap mr-2">{isVAT && '(VAT 별도)'}</div>
</div>
<div className="reject-gray whitespace-nowrap">[{koreanPrice} ]</div>
</div>
</div>
{priceErrorMessage && <div className="body-5 text-negative pl-[126px]">{priceErrorMessage}</div>}
</div>
<div className="flex w-full gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]"> </div>
<div className="flex w-full items-center justify-between pr-20 flex-wrap">
{['협력사배송', '지정택배배송', '픽업배송'].map((opt) => (
<SelectRadio
key={opt}
text={opt === '협력사배송' ? '협력사 배송' : opt}
value={opt}
name="rejectcm"
selectedValue={selectedReason}
onChange={(v) => {
setSelectedReason(v)
setRadioErrorMessage('')
}}
errorMessage={radioErrorMessage}
disabled={isDisabled}
/>
))}
</div>
</div>
<div className="flex w-full items-center justify-center mt-4">
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
</div>
</div>
)
}

View File

@ -0,0 +1,155 @@
import { useState, useMemo } from 'react'
import { cn } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
import { findRestoreScript, restoreRejectRSP } from '@/features/chat/lib/rejectForm'
const MAX_PRICE = 999999999999999
const REASONS = [
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
]
// 협상 합의 불가 시: 최종 제안 단가 + 합의 불가 사유 입력 폼
export function RejectRSP() {
const isLoading = useChatStore((s) => s.isLoading)
const sendMessage = useChatStore((s) => s.sendMessage)
const messages = useChatStore((s) => s.messages)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const isVAT = item_vat_yn === 'VAT별도'
// 폼 값은 이전 제출 내역에서 1회만 복원 (setter 미사용 = lazy 초기화 전용)
const restored = useState(() => restoreRejectRSP(findRestoreScript(messages, 'rejectRSP')))[0]
const [price, setPrice] = useState(restored.price)
const [selectedReason, setSelectedReason] = useState(restored.selectedReason)
const [reason, setReason] = useState(restored.reason)
const [otherErrorMessage, setOtherErrorMessage] = useState('')
const [priceErrorMessage, setPriceErrorMessage] = useState('')
const [radioErrorMessage, setRadioErrorMessage] = useState('')
const isSubmitted = useMemo(
() => Boolean(findRestoreScript(messages, 'rejectRSP')),
[messages],
)
const isValid =
Boolean(price && parseInt(price) > 0) &&
Boolean(selectedReason) &&
(selectedReason !== '기타' || reason.trim() !== '')
const handlePriceChange = (value: string) => {
const numeric = value.replace(/\D/g, '')
if (numeric && parseInt(numeric) > MAX_PRICE) return
setPrice(numeric)
if (numeric) setPriceErrorMessage('')
}
const handleRadioChange = (value: string) => {
setSelectedReason(value)
setRadioErrorMessage('')
if (value !== '기타') {
setReason('')
setOtherErrorMessage('')
}
}
const koreanPrice = (() => {
if (!price) return '영'
const n = parseInt(price)
return Number.isNaN(n) || n === 0 ? '영' : numberToKorean(n)
})()
const handleSubmit = () => {
setPriceErrorMessage(!price || parseInt(price) === 0 ? '가격을 입력해주세요.' : '')
setRadioErrorMessage(!selectedReason ? '사유를 선택해주세요.' : '')
setOtherErrorMessage(selectedReason === '기타' && !reason.trim() ? '기타 사유를 입력해주세요.' : '')
if (isValid && !isLoading && !isSubmitted) {
const script =
selectedReason === '기타'
? `공급희망가격-${price}, 합의불가사유-기타-${reason.trim()}`
: `공급희망가격-${price}, 합의불가사유-${selectedReason}`
sendMessage(script, 'text')
}
}
const isDisabled = isLoading || isSubmitted
return (
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-2 transition-all duration-300">
<div className="flex w-full h-[48px] bg-[#CD2A2D] headline-3 text-neutral-00 rounded-[12px] items-center justify-center">
</div>
<div className="reject break-keep py-2">
. .
<br />
,
.
</div>
<div className="flex flex-col w-full gap-1">
<div className="flex w-full items-start gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 flex-shrink-0 w-[110px]"> </div>
<div className="flex flex-wrap w-full items-center pt-2">
<div className="flex items-center gap-2">
<input
type="text"
className={cn(
'text-right max-w-[220px] h-full max-h-[35px] border p-2 body-3 placeholder:text-neutral-60 focus:border-neutral-70 rounded-[8px] outline-none border-neutral-40',
isDisabled ? 'bg-neutral-10 text-neutral-60 cursor-not-allowed' : 'bg-white text-neutral-90',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isDisabled}
/>
<div className="reject whitespace-nowrap mr-2">{isVAT && '(VAT 별도)'}</div>
</div>
<div className="reject-gray whitespace-nowrap">[{koreanPrice} ]</div>
</div>
</div>
{priceErrorMessage && <div className="body-5 text-negative pl-[126px]">{priceErrorMessage}</div>}
</div>
<div className="flex w-full gap-4">
<div className="flex reject-2 whitespace-nowrap pt-2 pb-2 flex-shrink-0 w-[110px]"> </div>
<div className="flex w-full flex-col mt-2 gap-2">
{REASONS.map((r) => (
<SelectRadio
key={r.value}
text={r.text}
value={r.value}
name="rejectrsp"
selectedValue={selectedReason}
onChange={handleRadioChange}
errorMessage={radioErrorMessage}
disabled={isDisabled}
/>
))}
<OtherReason
inputValue={reason}
setInputValue={(v) => {
if (selectedReason !== '기타') return
setReason(v)
if (v.trim()) setOtherErrorMessage('')
}}
errorMessage={otherErrorMessage}
selectedValue={selectedReason}
onChange={handleRadioChange}
isError={!!radioErrorMessage}
disabled={isDisabled}
/>
</div>
</div>
<div className="flex w-full items-center justify-center mt-6">
<SubmitButton isValid={isValid} isLoading={isLoading} isSubmitted={isSubmitted} onSubmit={handleSubmit} />
</div>
</div>
)
}

View File

@ -0,0 +1,91 @@
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import type { ChatSummary } from '@/features/chat/types'
// 협상 결과 요약 카드
export function Summary({ data }: { data: ChatSummary }) {
return (
<div className="flex flex-col w-full p-10 bg-neutral-00 rounded-[28px] gap-4">
<h1 className="headline-3 text-neutral-90"> </h1>
<div className="body-1-read-r">
.
.
</div>
<div className="flex flex-col">
<div className="body-1-read-r"> : {data.nego_start_date || '-'}</div>
<div className="body-1-read-r"> : {data.nego_end_date || '-'}</div>
<div className="body-1-read-r">
: {data.supplier_name || '-'} ({data.md_phone_number || '-'}){' '}
{data.supplier_manager_email || '-'}
</div>
<div className="body-1-read-r"> </div>
</div>
<div className="flex flex-col gap-4">
<DetailText title="상품 코드" value={data.item_code || '-'} />
<DetailText title="상품 명" value={data.item_name || '-'} />
<DetailText title="모델 명" value={data.item_model || '-'} />
<DetailText title="제품 규격" value={data.item_spec || '-'} />
<DetailText title="최소 주문" value={data.item_moq || '-'} />
<DetailText title="배송 형태" value={data.item_delivery_type || '-'} />
<DetailText title="배송 리드타임" value={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 ({data.nego_end_date ? addOneYear(data.nego_end_date) : '-'})
</div>
<div className="body-1-read-r">
MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
</div>
</div>
<div className="body-1-read-b"> .</div>
</div>
)
}
function addOneYear(dateStr: string): string {
const match = dateStr.match(/(\d{4})년 (\d{2})월 (\d{2})일 (\d{2})시 (\d{2})분/)
if (!match) return '-'
const [, year, month, day, hour, minute] = match
const date = new Date(parseInt(year) + 1, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute))
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
}
function DetailText({ title, value }: { title: string; value: string }) {
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex-1 whitespace-pre-wrap">
<span className="body-1-read-b">{title} :&nbsp;</span>
<span className="body-1-read-r">{value}</span>
</div>
</div>
)
}
function PriceText({ price, isVAT }: { price: number; isVAT: boolean }) {
const numberString = price.toLocaleString()
return (
<div className="flex items-start gap-2">
<Dot />
<div className="flex flex-1">
<div className="body-1-read-b flex flex-shrink-0"> :</div>
<div className="flex flex-1 flex-wrap">
<span className="body-1-read-b text-negative break-keep">&nbsp;{numberString}</span>
<span className="body-1-read-b text-neutral-90 break-keep">
&nbsp;({numberToKorean(parseInt(numberString.replace(/,/g, '')))})
</span>
<span className="body-1-read-b text-neutral-90 break-keep">&nbsp;{isVAT ? 'VAT포함' : 'VAT별도'}</span>
</div>
</div>
</div>
)
}
function Dot() {
return (
<div className="flex justify-end items-center w-[26px] h-[30px]">
<p className="body-1-read-r"></p>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib'
// reject 폼 공유 컨트롤 (RejectCM / RejectRSP 공용)
export function SelectRadio({
text,
value,
name,
selectedValue,
onChange,
errorMessage,
disabled,
}: {
text: string
value: string
name: string
selectedValue: string
onChange: (value: string) => void
errorMessage: string
disabled?: boolean
}) {
const isChecked = selectedValue === value
return (
<label className={cn('flex items-center gap-2', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
<input
className={cn(
'appearance-none w-[16px] h-[16px] min-w-[16px] min-h-[16px] rounded-full border-[1.5px] whitespace-nowrap',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
isChecked
? 'border-neutral-70 bg-[radial-gradient(circle,var(--neutral-70)_40%,white_40%)]'
: errorMessage
? 'border-negative bg-white'
: 'border-neutral-60 bg-white',
)}
type="radio"
name={name}
value={value}
checked={isChecked}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<div className={cn('reject break-keep', disabled && 'text-neutral-60')}>{text}</div>
</label>
)
}
export function SubmitButton({
isValid,
isLoading,
isSubmitted,
onSubmit,
}: {
isValid: boolean
isLoading: boolean
isSubmitted: boolean
onSubmit: () => void
}) {
if (isSubmitted) return null
const style = isLoading
? 'bg-neutral-40 text-neutral-80 cursor-not-allowed'
: isValid
? 'bg-neutral-90 text-white cursor-pointer hover:bg-neutral-80'
: 'bg-neutral-20 text-neutral-60 cursor-not-allowed'
return (
<button
className={cn(
'flex items-center justify-center w-[280px] h-[48px] rounded-[12px] text-base font-bold tracking-[-0.32px] transition-all duration-200',
style,
)}
onClick={onSubmit}
disabled={!isValid || isLoading}
>
{isLoading ? <Loader2 className="size-5 animate-spin" /> : '제출'}
</button>
)
}

View File

@ -0,0 +1,157 @@
import { useState, useRef, useId } from 'react'
import { useChatStore } from '@/features/chat/stores/useChatStore'
// 단위(원/%) 오버레이 + 전송 버튼이 붙은 값 입력 (Percent / Price 공용)
function InputWithUnit({
value,
onChange,
onSubmit,
placeholder,
unit,
error,
inputRef,
ariaLabel,
}: {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onSubmit: () => void
placeholder: string
unit: string
error: string
inputRef: React.RefObject<HTMLInputElement | null>
ariaLabel: string
}) {
const errorId = useId()
return (
<div className="relative flex flex-col gap-3">
<div className="relative flex items-center min-w-[480px]">
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={onChange}
onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
className="h-[50px] flex-1 bg-white border-none rounded-[999px] outline-none ring-1 ring-primary headline-3 text-negative placeholder:text-neutral-60 pl-[32px]"
placeholder={placeholder}
aria-label={ariaLabel}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
<div className="absolute left-[32px] headline-3 text-neutral-70 pointer-events-none">
<span className="opacity-0">{value}</span>
{value && <span className="ml-1">{unit}</span>}
</div>
<button
type="button"
onClick={onSubmit}
className="absolute right-0 flex items-center justify-center min-w-[120px] h-[50px] px-[32px] bg-primary hover:brightness-[0.97] rounded-[999px] title-2 text-primary-foreground cursor-pointer whitespace-nowrap transition-colors duration-200"
aria-label="전송"
>
</button>
</div>
{error && (
<div id={errorId} role="alert" className="absolute top-[62px] left-1/2 -translate-x-1/2 title-2 text-negative w-full text-center">
{error}
</div>
)}
</div>
)
}
export function Percent() {
const [percent, setPercent] = useState('')
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const sendMessage = useChatStore((s) => s.sendMessage)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (value === '') {
setPercent('')
setError('')
return
}
if (!/^\d*\.?\d*$/.test(value)) return
if (value.includes('.')) {
const parts = value.split('.')
if (parts[1] && parts[1].length > 1) return
} else if (value.length > 2) {
return
}
if (parseFloat(value) > 80) return
setPercent(value)
setError('')
}
const handleSubmit = () => {
if (!percent || percent === '.') {
setError('숫자를 입력해주세요')
return
}
sendMessage(`${parseFloat(percent).toString()}%`, 'percent')
setError('')
}
return (
<InputWithUnit
value={percent}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder="할인율(%) 소수점 첫째 자리까지"
unit="%"
error={error}
inputRef={inputRef}
ariaLabel="할인율 입력"
/>
)
}
export function Price({ priceErrorMessage }: { priceErrorMessage?: string }) {
const [inputValue, setInputValue] = useState('')
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const sendMessage = useChatStore((s) => s.sendMessage)
const setPriceErrorMessage = useChatStore((s) => s.setPriceErrorMessage)
const displayError = priceErrorMessage || error
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (priceErrorMessage) setPriceErrorMessage('')
if (value === '') {
setInputValue('')
setError('')
return
}
const numberOnly = value.replace(/,/g, '')
if (!/^\d*$/.test(numberOnly)) return
if (parseInt(numberOnly) > 999999999999) return
setInputValue(numberOnly.replace(/\B(?=(\d{3})+(?!\d))/g, ','))
setError('')
}
const handleSubmit = () => {
if (!inputValue) {
if (priceErrorMessage) setPriceErrorMessage('')
setError('가격을 입력해주세요')
return
}
sendMessage(`${inputValue}`, 'price')
setError('')
}
return (
<InputWithUnit
value={inputValue}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder="가격(원)을 입력해주세요"
unit="원"
error={displayError}
inputRef={inputRef}
ariaLabel="가격 입력"
/>
)
}

View File

@ -0,0 +1,14 @@
import { useChatInit } from '@/features/chat/hooks/useChatInit'
import { ChatSection } from '@/features/chat/components/ChatSection'
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
// 콘텐츠 영역: 채팅 + 우측 메뉴. mock 데이터를 스토어에 적재한다.
export function ChatContainer() {
useChatInit()
return (
<div className="flex flex-1 min-h-0 w-full">
<ChatSection />
<MenuSection />
</div>
)
}

View File

@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { MOCK_CHAT_INIT } from '@/features/chat/mocks/mockChatInit'
import { MOCK_MESSAGES } from '@/features/chat/mocks/mockMessages'
// mock 세션/대화 데이터를 스토어에 적재 (추후 API 조회로 교체)
export function useChatInit() {
const setInitData = useChatInitStore((s) => s.setInitData)
const setMessages = useChatStore((s) => s.setMessages)
useEffect(() => {
setInitData(MOCK_CHAT_INIT)
setMessages(MOCK_MESSAGES)
}, [setInitData, setMessages])
}

View File

@ -0,0 +1,4 @@
// 공개 API (페이지에서 쓰는 것만)
export { ChatContainer } from '@/features/chat/containers/ChatContainer'
export { ItemSection } from '@/features/chat/components/ItemSection'
export { RemainingTime } from '@/features/chat/components/RemainingTime'

View File

@ -0,0 +1,45 @@
const digits = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구']
const units = ['', '십', '백', '천']
const higherUnits = ['', '만', '억', '조', '경', '해']
// 숫자를 한글 표기로 (예: 12000 → 일만이천)
export function numberToKorean(num: number): string {
if (num === 0) return '영'
const groups: number[] = []
let numStr = num.toString()
while (numStr.length > 0) {
groups.unshift(parseInt(numStr.slice(-4)))
numStr = numStr.slice(0, -4)
}
let result = ''
const groupLen = groups.length
for (let idx = 0; idx < groupLen; idx++) {
const group = groups[idx]
if (group === 0) continue
const groupStr = processGroup(group, idx === 0)
result += groupStr + higherUnits[groupLen - idx - 1]
}
return result
}
function processGroup(num: number, isFirstGroup = false): string {
const numStr = num.toString().padStart(4, '0')
let result = ''
for (let idx = 0; idx < numStr.length; idx++) {
const digit = parseInt(numStr[idx])
const unit = units[3 - idx]
if (digit === 0) continue
if (digit === 1) {
if (unit !== '') {
result += isFirstGroup && unit === '천' ? digits[digit] + unit : unit
} else {
result += '일'
}
} else {
result += digits[digit] + unit
}
}
return result
}

View File

@ -0,0 +1,24 @@
import type { ChatMessage } from '@/features/chat/types'
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
const reversed = [...messages].reverse()
const idx = reversed.findIndex((m) => m.bot_chat_type === type)
if (idx > 0 && reversed[idx - 1]?.sender === 'user') return reversed[idx - 1].script
return null
}
export function extractPart(script: string | null, prefix: string): string {
return script?.split(', ').find((p) => p.startsWith(prefix))?.replace(prefix, '') ?? ''
}
// 재협상(RSP) 폼 복원: 가격 + 사유(기타-내용 포함)
export function restoreRejectRSP(script: string | null) {
const empty = { price: '', selectedReason: '', reason: '' }
if (!script) return empty
const parts = script.split(', ')
const price = parts.find((p) => p.startsWith('공급희망가격-'))?.replace('공급희망가격-', '') ?? ''
const reasonRaw = parts.find((p) => p.startsWith('합의불가사유-'))?.replace('합의불가사유-', '') ?? ''
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
return { price, selectedReason: reasonRaw, reason: '' }
}

View File

@ -0,0 +1,15 @@
// 마감까지 남은 시간 'HH시간 MM분 SS초'. 지났으면 종료 문구, 잘못된 값은 '-'.
export function getTimeRemaining(targetDateTime: string | Date): string {
const now = Date.now()
const target = new Date(targetDateTime).getTime()
if (Number.isNaN(target)) return '-'
const diff = target - now
if (diff <= 0) return '종료되었습니다.'
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(hours)}시간 ${pad(minutes)}${pad(seconds)}`
}

View File

@ -0,0 +1,25 @@
import type { ChatMessage, UserButtonConfig } from '@/features/chat/types'
export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
export function deriveUserButtonConfig(
messages: ChatMessage[],
isLoading: boolean,
priceErrorMessage: string,
): UserButtonConfig {
if (isLoading) return { type: 'loading' }
if (!messages || messages.length === 0) return { type: '', text: '' }
const last = messages[messages.length - 1]
const mode = last.next_input_mode
const options = last.next_input_type
if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT }
if (mode === 'confirm') return { type: 'one-black', text: options?.[0] || '' }
if (mode === 'yes_no') return { type: 'black-white', textList: options || [] }
if (mode === 'percent') return { type: 'percent' }
if (mode === 'price') return { type: 'price', priceErrorMessage: priceErrorMessage || undefined }
if (mode === 'delivery_type') return { type: 'three-black', textList: options || [] }
return { type: '', text: '' }
}

View File

@ -0,0 +1,25 @@
import type { ChatInitData } from '@/features/chat/types'
// 마감까지 카운트다운이 보이도록 현재 시각 기준 미래로 설정
const END_TIME = new Date(Date.now() + 95 * 60 * 1000).toISOString()
// 임시 세션/상품 데이터 (API 연동 전)
export const MOCK_CHAT_INIT: ChatInitData = {
session_id: 's-001',
item_id: 'item-001',
quotation_id: 'qt-001',
item_name: '사무용 노트북 14인치',
item_code: 'IMK-10231',
item_image: '',
item_price: 1350000,
item_model_name: 'NB-1400-PRO',
item_maker_name: '삼성전자',
item_vat_yn: 'VAT별도',
item_delivery_fee_yn: 'N',
item_min_order_quantity: '10 EA',
item_lead_time: '7일',
item_spec: 'Intel Core i7 / 16GB RAM / 512GB SSD / 14인치 FHD',
quotation_memo:
'납기 엄수 부탁드립니다.\n세금계산서는 월말 일괄 발행합니다.\n상세 사양은 첨부 문서를 확인해주세요.',
quotation_end_time: END_TIME,
}

View File

@ -0,0 +1,106 @@
import type { ChatMessage, ChatSummary } from '@/features/chat/types'
// 전 메시지 템플릿을 한눈에 보기 위한 쇼케이스 목 대화 (실제 협상 흐름 아님)
const SUMMARY: ChatSummary = {
md_name: '김엠디',
item_moq: '10 EA',
md_email: 'md@example.com',
item_code: 'IMK-10231',
item_name: '사무용 노트북 14인치',
item_spec: 'Intel Core i7 / 16GB / 512GB SSD',
item_isVAT: false,
item_maker: '삼성전자',
item_model: 'NB-1400-PRO',
final_price: 1200000,
nego_end_date: '2026년 06월 17일 14시 30분',
supplier_name: '대한상사',
item_lead_time: '7일',
md_phone_number: '02-1234-5678',
nego_start_date: '2026년 06월 17일 14시 00분',
item_display_date: '2026년 06월 10일',
item_delivery_type: '협력사배송',
supplier_manager_name: '이담당',
supplier_manager_email: 'sales@example.com',
delivery_type: '협력사배송',
}
const base = {
bot_chat_type: null,
user_input_type: null,
script: null,
chat_end: false,
next_input_mode: null,
next_input_type: null,
summary: null,
indicator_value: null,
} as const
export const MOCK_MESSAGES: ChatMessage[] = [
{
...base,
chat_id: 'm1',
sender: 'bot',
script:
'안녕하세요, 협상을 시작하겠습니다. 본 협상은 자동으로 진행되며, 안내에 따라 응답해주시면 됩니다.',
step: '서비스안내',
display_step: '서비스안내',
},
{
...base,
chat_id: 'm2',
sender: 'bot',
bot_chat_type: 'indicator',
indicator_value: 62,
script: '현재까지의 협상 성공률은 아래와 같습니다.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm3',
sender: 'user',
user_input_type: 'price',
script: '1,200,000원',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm4',
sender: 'bot',
bot_chat_type: 'summaryCM',
summary: SUMMARY,
script: '제시해주신 금액으로 투찰 결과를 요약해드립니다.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm5',
sender: 'bot',
bot_chat_type: 'summaryRSP',
summary: SUMMARY,
script: '협상이 완료되었습니다. 최종 결과를 요약해드립니다.',
step: '협상종료',
display_step: '협상종료',
},
{
...base,
chat_id: 'm6',
sender: 'bot',
bot_chat_type: 'rejectCM',
script: '제시 금액이 수용되지 않았습니다. 최종 공급 희망 가격과 배송 형태를 입력해주세요.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm7',
sender: 'bot',
script: '추가로 제시할 가격이 있다면 입력해주세요.',
next_input_mode: 'price',
step: '가격협상',
display_step: '가격협상',
},
]

View File

@ -0,0 +1,32 @@
import { create } from 'zustand'
import type { ChatInitData } from '@/features/chat/types'
interface ChatInitStore extends ChatInitData {
setInitData: (data: Partial<ChatInitData>) => void
reset: () => void
}
const initialState: ChatInitData = {
session_id: '',
item_id: '',
quotation_id: '',
item_name: '',
item_code: '',
item_image: '',
item_price: 0,
item_model_name: '',
item_maker_name: '',
item_vat_yn: '',
item_delivery_fee_yn: '',
item_min_order_quantity: '',
item_lead_time: '',
item_spec: '',
quotation_memo: '',
quotation_end_time: '',
}
export const useChatInitStore = create<ChatInitStore>((set) => ({
...initialState,
setInitData: (data) => set(data),
reset: () => set(initialState),
}))

View File

@ -0,0 +1,63 @@
import { create } from 'zustand'
import type { ChatMessage, UserButtonConfig, UserInputType } from '@/features/chat/types'
import { deriveUserButtonConfig } from '@/features/chat/lib/userButtonConfig'
type ChatStore = {
messages: ChatMessage[]
userButtonConfig: UserButtonConfig
isLoading: boolean
priceErrorMessage: string
setMessages: (messages: ChatMessage[]) => void
setIsLoading: (isLoading: boolean) => void
setPriceErrorMessage: (message: string) => void
sendMessage: (text: string, inputType?: UserInputType) => void
}
let mockSeq = 0
export const useChatStore = create<ChatStore>((set, get) => ({
messages: [],
userButtonConfig: { type: '', text: '' },
isLoading: false,
priceErrorMessage: '',
setMessages: (messages) =>
set((s) => ({
messages,
userButtonConfig: deriveUserButtonConfig(messages, s.isLoading, s.priceErrorMessage),
})),
setIsLoading: (isLoading) =>
set((s) => ({
isLoading,
userButtonConfig: deriveUserButtonConfig(s.messages, isLoading, s.priceErrorMessage),
})),
setPriceErrorMessage: (priceErrorMessage) =>
set((s) => ({
priceErrorMessage,
userButtonConfig: deriveUserButtonConfig(s.messages, s.isLoading, priceErrorMessage),
})),
// mock: API 미연동이라 사용자 메시지를 로컬에 추가만 한다 (실제 협상 진행 로직 없음)
sendMessage: (text, inputType = 'text') => {
const { messages } = get()
const last = messages[messages.length - 1]
const userMsg: ChatMessage = {
chat_id: `mock-user-${mockSeq++}`,
sender: 'user',
bot_chat_type: null,
user_input_type: inputType,
script: text,
chat_end: false,
next_input_mode: null,
next_input_type: null,
step: last?.step ?? '',
display_step: last?.display_step ?? '',
summary: null,
indicator_value: null,
}
const next = [...messages, userMsg]
set({ messages: next, priceErrorMessage: '', userButtonConfig: deriveUserButtonConfig(next, false, '') })
},
}))

View File

@ -0,0 +1,82 @@
export type ChatSender = 'bot' | 'user'
export type BotChatType = 'indicator' | 'summaryRSP' | 'summaryCM' | 'rejectRSP' | 'rejectCM'
export type UserInputType = 'text' | 'percent' | 'price'
// 봇 마지막 메시지가 요구하는 다음 입력 형태 → UserButton 구성을 결정
export type NextInputMode = 'confirm' | 'yes_no' | 'percent' | 'price' | 'delivery_type'
export type ChatSummary = {
md_name: string
item_moq: string
md_email: string
item_code: string
item_name: string
item_spec: string
item_isVAT: boolean
item_maker: string
item_model: string
final_price: number
nego_end_date: string
supplier_name: string
item_lead_time: string
md_phone_number: string
nego_start_date: string
item_display_date: string
item_delivery_type: string
supplier_manager_name: string
supplier_manager_email: string
delivery_type: string | null
}
export type ChatMessage = {
chat_id: string
sender: ChatSender
bot_chat_type: BotChatType | null
user_input_type: UserInputType | null
script: string | null
chat_end: boolean
next_input_mode: NextInputMode | null
next_input_type: string[] | null
step: string
display_step: string
summary: ChatSummary | null
indicator_value: number | null
}
export type UserButtonType =
| 'one-black'
| 'one-gray'
| 'black-white'
| 'percent'
| 'three-black'
| 'price'
| 'loading'
| ''
export type UserButtonConfig = {
type: UserButtonType
text?: string
textList?: string[]
priceErrorMessage?: string
}
export type ChatInitData = {
session_id: string
item_id: string
quotation_id: string
item_name: string
item_code: string
item_image: string
item_price: number
item_model_name: string
item_maker_name: string
item_vat_yn: string
item_delivery_fee_yn: string
item_min_order_quantity: string
item_lead_time: string
item_spec: string
quotation_memo: string
quotation_end_time: string
}

View File

@ -0,0 +1,24 @@
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 function ActionSection() {
return (
<div className="flex w-full py-[35px] items-center justify-end gap-3">
{/* TODO: 협상 참여 동작 연동 */}
<button type="button" className={cn(PILL, 'bg-primary text-primary-foreground')}>
</button>
{/* TODO: 거부 동작 연동 */}
<button
type="button"
className={cn(PILL, 'bg-background text-primary border border-primary')}
>
</button>
</div>
)
}

View File

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

View File

@ -0,0 +1,102 @@
import { cn, interactive } from '@/lib'
import { buildPageItems } from '@/features/list/lib/pagination'
import { ArrowIcon, DotsIcon } from '@/features/list/components/paginationIcons'
interface PaginationProps {
totalPages: number
currentPage: number
onPageChange: (page: number) => void
}
export function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) {
if (totalPages === 0) {
return <div className="w-full my-[35px] h-[50px]" />
}
const items = buildPageItems(currentPage, totalPages)
return (
<div className="flex w-full my-[35px]">
<div className="flex w-full h-[50px] items-center justify-center gap-3">
<NavButton
dir="prev"
enabled={currentPage > 1}
onClick={() => onPageChange(currentPage - 1)}
/>
<div className="flex items-center justify-center h-full rounded-[999px] border border-neutral-30 bg-neutral-00">
<div className="flex items-center justify-center gap-1 h-[38px]">
{items.map((item, idx) =>
item === 'dots' ? (
<DotsIcon key={`dots-${idx}`} />
) : (
<PageNumber
key={item}
page={item}
active={item === currentPage}
onClick={() => onPageChange(item)}
/>
),
)}
</div>
</div>
<NavButton
dir="next"
enabled={currentPage < totalPages}
onClick={() => onPageChange(currentPage + 1)}
/>
</div>
</div>
)
}
function PageNumber({
page,
active,
onClick,
}: {
page: number
active: boolean
onClick: () => void
}) {
return (
<button
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 (
<button
type="button"
aria-label={dir === 'prev' ? '이전 페이지' : '다음 페이지'}
disabled={!enabled}
onClick={enabled ? onClick : undefined}
className={cn(
'flex items-center justify-center w-[50px] h-[50px] rounded-full border border-neutral-30 bg-neutral-00 select-none',
enabled ? cn('text-neutral-60', interactive) : 'text-neutral-40 cursor-default',
)}
>
<ArrowIcon dir={dir} />
</button>
)
}

View File

@ -0,0 +1,79 @@
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] table-fixed 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 overflow-hidden text-ellipsis whitespace-nowrap',
col.align,
)}
>
{col.render ? col.render(item) : item[col.key] || '-'}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
// 로딩/빈 상태 행
function StateRow({ children }: { children: ReactNode }) {
return (
<tr>
<td colSpan={COLUMNS.length} className="h-[240px]">
<div className="flex items-center justify-center">{children}</div>
</td>
</tr>
)
}

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import { useList } from '@/features/list/hooks/useList'
import { ActionSection } from '@/features/list/components/ActionSection'
import { TableSection } from '@/features/list/components/TableSection'
import { Pagination } from '@/features/list/components/Pagination'
export function ContentContainer() {
const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } =
useList()
return (
// 좌우 거터(80px) 일괄 적용
<div className="flex flex-1 flex-col min-h-0 px-[80px]">
<ActionSection />
<TableSection
items={items}
isLoading={isLoading}
selectedId={selectedId ?? undefined}
onItemClick={handleItemClick}
/>
<Pagination
totalPages={totalPages}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,57 @@
import { useMemo, useState } from 'react'
import { useListStore } from '@/features/list/stores/useListStore'
import { MOCK_ITEMS } from '@/features/list/mocks/mockItems'
import type { ListItem } from '@/features/list/types'
const PAGE_SIZE = 20
// 목데이터 클라이언트 필터 (추후 API 조회로 교체)
export function useList() {
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
useListStore()
const [selectedId, setSelectedId] = useState<string | null>(null)
const filtered = useMemo(() => {
const result = MOCK_ITEMS.filter(
(item) =>
(!selectedType || item.qt_type === selectedType) &&
(!selectedStatus || item.session_status === selectedStatus),
)
if (selectedDeadline) {
const dir = selectedDeadline === '남은 시간 적은 순' ? 1 : -1
result.sort(
(a, b) =>
(new Date(a.qt_end_time).getTime() - new Date(b.qt_end_time).getTime()) * dir,
)
}
return result
}, [selectedType, selectedStatus, selectedDeadline])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const items = useMemo(
() => filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[filtered, currentPage],
)
const selectedItem = useMemo(
() => items.find((item) => item.session_id === selectedId) ?? null,
[items, selectedId],
)
const handleItemClick = (item: ListItem) =>
setSelectedId((prev) => (prev === item.session_id ? null : item.session_id))
return {
items,
isLoading: false,
totalPages,
currentPage,
setCurrentPage,
selectedId,
selectedItem,
handleItemClick,
}
}

View File

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

View File

@ -0,0 +1,8 @@
// 'YYYY-MM-DD HH:mm'. 빈 값 '-', 파싱 실패 시 원본.
export function formatDateTime(value: string): string {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}

View File

@ -0,0 +1,18 @@
export type PageItem = number | 'dots'
// 9개 이하: 전체 / 초과: 앞4·뒤4 또는 1·중앙5·끝 + 생략(...)
export function buildPageItems(currentPage: number, totalPages: number): PageItem[] {
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
if (pages.length <= 9) return pages
const prev = pages.slice(0, 4)
const middle = pages.slice(3, pages.length - 3)
const next = pages.slice(pages.length - 4)
if (!middle.includes(currentPage)) {
return [...prev, 'dots', ...next]
}
const center = [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2]
return [1, 'dots', ...center, 'dots', totalPages]
}

View File

@ -0,0 +1,115 @@
import type { ListItem } from '@/features/list/types'
// 임시 목데이터 (API 연동 전)
export const MOCK_ITEMS: ListItem[] = [
{
session_id: 's-001',
session_status: '협상생성',
qt_type: '재견적',
qt_number: 'QT-2026-000101',
qt_end_time: '2026-06-18T18:00:00',
item_code: 'IMK-10231',
item_name: '사무용 노트북 14인치',
model_name: 'NB-1400-PRO',
maker_name: '삼성전자',
},
{
session_id: 's-002',
session_status: '협상중',
qt_type: '재협상',
qt_number: 'QT-2026-000102',
qt_end_time: '2026-06-17T12:30:00',
item_code: 'IMK-10232',
item_name: '레이저 복합기',
model_name: 'MFC-7890DW',
maker_name: '브라더',
},
{
session_id: 's-003',
session_status: '협상완료',
qt_type: '재견적',
qt_number: 'QT-2026-000103',
qt_end_time: '2026-06-20T09:00:00',
item_code: 'IMK-10233',
item_name: '27인치 4K 모니터',
model_name: 'U2723QE',
maker_name: '델',
},
{
session_id: 's-004',
session_status: '협상거부',
qt_type: '재협상',
qt_number: 'QT-2026-000104',
qt_end_time: '2026-06-19T15:45:00',
item_code: 'IMK-10234',
item_name: '무선 기계식 키보드',
model_name: 'MX-KEYS-M',
maker_name: '로지텍',
},
{
session_id: 's-005',
session_status: '미참여',
qt_type: '재견적',
qt_number: 'QT-2026-000105',
qt_end_time: '2026-06-22T11:00:00',
item_code: 'IMK-10235',
item_name: 'A4 무선 레이저프린터',
model_name: 'SL-M2030',
maker_name: 'HP',
},
{
session_id: 's-006',
session_status: '협상중',
qt_type: '재견적',
qt_number: 'QT-2026-000106',
qt_end_time: '2026-06-16T20:00:00',
item_code: 'IMK-10236',
item_name: '회의실 대형 디스플레이 65인치',
model_name: 'QM65R',
maker_name: '삼성전자',
},
{
session_id: 's-007',
session_status: '협상생성',
qt_type: '재협상',
qt_number: 'QT-2026-000107',
qt_end_time: '2026-06-25T17:00:00',
item_code: 'IMK-10237',
item_name: '인체공학 사무용 의자',
model_name: 'ERGO-700',
maker_name: '시디즈',
},
{
session_id: 's-008',
session_status: '협상완료',
qt_type: '재견적',
qt_number: 'QT-2026-000108',
qt_end_time: '2026-06-21T10:30:00',
item_code: 'IMK-10238',
item_name: '네트워크 스위치 24포트',
model_name: 'SG350-28',
maker_name: '시스코',
},
{
session_id: 's-009',
session_status: '미참여',
qt_type: '재협상',
qt_number: 'QT-2026-000109',
qt_end_time: '2026-06-23T14:00:00',
item_code: 'IMK-10239',
item_name: '외장 SSD 2TB',
model_name: 'T7-Shield-2T',
maker_name: '삼성전자',
},
{
session_id: 's-010',
session_status: '협상중',
qt_type: '재견적',
qt_number: 'QT-2026-000110',
qt_end_time: '2026-06-24T16:20:00',
item_code: 'IMK-10240',
item_name: '화상회의용 웹캠',
model_name: 'BRIO-4K',
maker_name: '로지텍',
},
]

View File

@ -0,0 +1,33 @@
import { create } from 'zustand'
interface ListState {
selectedType: string | null
selectedStatus: string | null
selectedDeadline: string | null
currentPage: number
toggleType: (item: string) => void
toggleStatus: (item: string) => void
toggleDeadline: (item: string) => void
setCurrentPage: (page: number) => void
resetFilters: () => void
}
const toggle = (current: string | null, item: string) => (current === item ? null : item)
export const useListStore = create<ListState>((set) => ({
selectedType: null,
selectedStatus: null,
selectedDeadline: null,
currentPage: 1,
toggleType: (item) =>
set((s) => ({ selectedType: toggle(s.selectedType, item), currentPage: 1 })),
toggleStatus: (item) =>
set((s) => ({ selectedStatus: toggle(s.selectedStatus, item), currentPage: 1 })),
toggleDeadline: (item) =>
set((s) => ({ selectedDeadline: toggle(s.selectedDeadline, item), currentPage: 1 })),
setCurrentPage: (page) => set({ currentPage: page }),
resetFilters: () =>
set({ selectedType: null, selectedStatus: null, selectedDeadline: null, currentPage: 1 }),
}))

View File

@ -0,0 +1,11 @@
export type ListItem = {
session_id: string
session_status: string
qt_type: string
qt_number: string
qt_end_time: string
item_code: string
item_name: string
model_name: string
maker_name: string
}

181
front/src/index.css Normal file
View File

@ -0,0 +1,181 @@
/* Pretendard (CDN, dynamic-subset) */
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.css');
@import "tailwindcss";
/* 디자인 토큰 (light 전용) */
:root {
color-scheme: light;
--radius: 0.625rem; /* 10px 기준 */
/* 브랜드 */
--brand-500: #5a83c1;
--brand-600: #254d8b; /* = --primary */
--brand-700: #1b3965;
/* Neutral */
--neutral-90: #151515;
--neutral-80: #303030;
--neutral-70: #606060;
--neutral-60: #808080;
--neutral-40: #dadbde;
--neutral-30: #eaebef;
--neutral-20: #f0f1f4;
--neutral-10: #f7f8fa;
--neutral-00: #ffffff;
/* 상태 / 테이블 */
--negative: #e71c3b;
--info: #4880ef;
--table-header: #e3e8f1;
--table-hover: #dbe6fc;
--table-selected: #b6ccf9;
/* 시맨틱 */
--background: var(--neutral-00);
--foreground: var(--neutral-90);
--surface: #eef0f4; /* 중립 라이트 그레이 (콘텐츠 면, 블루기 최소) */
--card: var(--neutral-00);
--card-foreground: var(--neutral-90);
--popover: var(--neutral-00);
--popover-foreground: var(--neutral-90);
--primary: var(--brand-600);
--primary-foreground: var(--neutral-00);
--secondary: var(--neutral-20);
--secondary-foreground: var(--neutral-90);
--muted: var(--neutral-10);
--muted-foreground: var(--neutral-60);
--accent: var(--neutral-20);
--accent-foreground: var(--neutral-90);
--border: var(--neutral-30);
--input: var(--neutral-30);
--ring: var(--brand-500);
--destructive: var(--negative);
--success: #10b981;
--warning: #f59e0b;
}
/* Tailwind 테마 매핑 (@theme inline) */
@theme inline {
/* 브랜드 */
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
/* Neutral */
--color-neutral-90: var(--neutral-90);
--color-neutral-80: var(--neutral-80);
--color-neutral-70: var(--neutral-70);
--color-neutral-60: var(--neutral-60);
--color-neutral-40: var(--neutral-40);
--color-neutral-30: var(--neutral-30);
--color-neutral-20: var(--neutral-20);
--color-neutral-10: var(--neutral-10);
--color-neutral-00: var(--neutral-00);
/* 상태 / 테이블 */
--color-negative: var(--negative);
--color-info: var(--info);
--color-table-header: var(--table-header);
--color-table-hover: var(--table-hover);
--color-table-selected: var(--table-selected);
/* 시맨틱 */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-surface: var(--surface);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
/* 폰트 */
--font-sans: 'Pretendard Variable', 'Geist Variable', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
--font-heading: var(--font-sans);
/* 모서리 반경 (--radius 배수) */
--radius-sm: calc(var(--radius) * 0.6); /* 6px */
--radius-md: calc(var(--radius) * 0.8); /* 8px */
--radius-lg: var(--radius); /* 10px */
--radius-xl: calc(var(--radius) * 1.4); /* 14px */
--radius-2xl: calc(var(--radius) * 1.8); /* 18px */
--radius-3xl: calc(var(--radius) * 2.2); /* 22px */
--radius-4xl: calc(var(--radius) * 2.6); /* 26px */
}
@layer base {
body {
margin: 0;
min-height: 100svh;
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-synthesis: none;
}
}
/* 타이포그래피 스케일 (headline / title / body / reject) */
@layer components {
.headline-3 { font-size: 24px; font-weight: 700; line-height: 30px; letter-spacing: -0.48px; }
.headline-4 { font-size: 20px; font-weight: 700; line-height: 30px; letter-spacing: -0.4px; }
.title-1 { font-size: 20px; font-weight: 600; letter-spacing: -0.4px; }
.title-2 { font-size: 18px; font-weight: 600; letter-spacing: -0.36px; }
.title-3 { font-size: 16px; font-weight: 600; letter-spacing: -0.32px; }
.title-5 { font-size: 14px; font-weight: 600; letter-spacing: -0.28px; }
.title-6 { font-size: 13px; font-weight: 600; letter-spacing: -0.26px; }
.body-1 { font-size: 20px; font-weight: 400; line-height: 30px; letter-spacing: -0.4px; }
.body-3 { font-size: 16px; font-weight: 400; letter-spacing: -0.32px; }
.body-5 { font-size: 14px; font-weight: 400; letter-spacing: -0.28px; }
.body-1-read-r { font-size: 20px; font-weight: 400; line-height: 150%; letter-spacing: -0.4px; }
.body-1-read-b { font-size: 20px; font-weight: 700; line-height: 150%; letter-spacing: -0.4px; }
.caption-1 { font-size: 13px; font-weight: 400; letter-spacing: -0.26px; }
/* 메뉴 섹션 제목 (MD안내/협상절차/이용방법) */
.menu-title { font-size: 20px; font-weight: 700; line-height: 25px; letter-spacing: -0.4px; }
/* 재견적/재협상 폼 */
.reject { font-size: 18px; font-weight: 400; line-height: 150%; letter-spacing: -0.4px; color: var(--neutral-90); }
.reject-2 { font-size: 18px; font-weight: 700; line-height: 150%; letter-spacing: -0.4px; color: var(--neutral-90); }
.reject-gray { font-size: 18px; font-weight: 400; line-height: 150%; letter-spacing: -0.4px; color: var(--neutral-60); }
}
/* 스크롤바 (webkit) */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--muted-foreground);
}
/* 채팅 영역 스크롤바 (배경과 구분되게 진하게) */
.chat-scroll::-webkit-scrollbar-thumb {
background-color: var(--neutral-40);
}
.chat-scroll::-webkit-scrollbar-thumb:hover {
background-color: var(--neutral-60);
}

View File

@ -0,0 +1,22 @@
import { type ReactNode } from 'react'
import { cn } from '@/lib'
const HEADER_BASE =
'flex w-full h-[56px] py-[18px] items-center rounded-t-[8px] bg-primary text-primary-foreground'
const HEADER_ALIGN = {
left: 'justify-start',
center: 'justify-center',
} as const
export interface MainHeaderBarProps {
align?: keyof typeof HEADER_ALIGN
className?: string
children: ReactNode
}
export function MainHeaderBar({ align = 'left', className, children }: MainHeaderBarProps) {
return <div className={cn(HEADER_BASE, HEADER_ALIGN[align], className)}>{children}</div>
}
export default MainHeaderBar

View File

@ -0,0 +1,74 @@
import { type ReactNode } from 'react'
import { Logo } from '@/components'
import { cn } from '@/lib'
// 좌측 폭: list=반응형 비율 / chat=고정 350px
const SIDEBAR_WIDTH = {
list:
'w-[20%] max-w-[320px] ' +
'max-[1520px]:w-[23%] max-[1410px]:w-[25%] ' +
'max-[1180px]:w-[27%] max-[1024px]:w-[29%] max-[960px]:w-[33%]',
chat: 'w-[350px]',
} as const
const styles = {
root: 'flex w-full min-h-screen max-h-screen bg-background',
scrollX: 'flex w-full min-h-screen max-h-screen overflow-x-auto overflow-y-hidden',
scrollXInner: 'flex w-full min-h-screen max-h-screen min-w-[1350px] bg-background',
sidebar: 'flex flex-col h-screen pt-2 bg-background',
logoHeader:
'flex w-full h-[56px] pl-[32px] pr-[24px] py-[8px] items-center justify-between shrink-0',
main: 'flex flex-1 flex-col h-screen overflow-hidden pt-2 pr-2 pb-2 pl-0',
// 다크 헤더와 합쳐져 카드 형태
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden rounded-b-[8px] bg-surface',
} as const
export type SidebarWidth = keyof typeof SIDEBAR_WIDTH
export interface MainLayoutProps {
sidebarWidth?: SidebarWidth
/** 루트 가로 스크롤 + 최소폭 (chat 전용) */
scrollX?: boolean
logoAction?: ReactNode
sidebar: ReactNode
header: ReactNode
children?: ReactNode
}
export function MainLayout({
sidebarWidth = 'list',
scrollX = false,
logoAction,
sidebar,
header,
children,
}: MainLayoutProps) {
const panes = (
<>
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
<div className={styles.logoHeader}>
<Logo size="sm" />
{logoAction}
</div>
{sidebar}
</aside>
<main className={styles.main}>
{header}
<div className={styles.content}>{children}</div>
</main>
</>
)
if (!scrollX) {
return <div className={styles.root}>{panes}</div>
}
return (
<div className={styles.scrollX}>
<div className={styles.scrollXInner}>{panes}</div>
</div>
)
}
export default MainLayout

View File

@ -0,0 +1,4 @@
export { MainLayout } from '@/layouts/MainLayout'
export type { MainLayoutProps, SidebarWidth } from '@/layouts/MainLayout'
export { MainHeaderBar } from '@/layouts/MainHeaderBar'
export type { MainHeaderBarProps } from '@/layouts/MainHeaderBar'

6
front/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
// className 병합. tailwind-merge 아님 — 같은 속성 충돌 시 CSS 출력 순서가 승자.
export type ClassValue = string | number | false | null | undefined
export function cn(...classes: ClassValue[]): string {
return classes.filter(Boolean).join(' ')
}

4
front/src/lib/index.ts Normal file
View File

@ -0,0 +1,4 @@
// 전역 순수 유틸/스타일
export { cn } from '@/lib/cn'
export type { ClassValue } from '@/lib/cn'
export { interactive } from '@/lib/interactive'

View File

@ -0,0 +1,3 @@
// 공통 인터랙션: hover=brightness↓, active=scale↓. transform/filter 만 전환(잔상 방지).
export const interactive =
'transition-[transform,filter] duration-150 ease-out cursor-pointer hover:brightness-[0.97] active:scale-[0.98]'

13
front/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from '@/core/Provider'
import '@/index.css'
import App from '@/App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider>
<App />
</Provider>
</StrictMode>,
)

View File

@ -0,0 +1,49 @@
import { useNavigate } from 'react-router'
import { List } from 'lucide-react'
import { cn, interactive } from '@/lib'
import { MainLayout, MainHeaderBar } from '@/layouts'
import { ChatContainer, ItemSection, RemainingTime } from '@/features/chat'
import { SidebarFooter } from '@/features/auth'
export function ChatPage() {
return (
<MainLayout
sidebarWidth="chat"
scrollX
logoAction={<ListNavButton />}
sidebar={<Sidebar />}
header={
<MainHeaderBar className="px-[140px]">
<RemainingTime />
</MainHeaderBar>
}
>
<ChatContainer />
</MainLayout>
)
}
function ListNavButton() {
const navigate = useNavigate()
return (
<button
type="button"
aria-label="목록으로 이동"
onClick={() => navigate('/list')}
className={cn('text-foreground', interactive)}
>
<List size={24} />
</button>
)
}
function Sidebar() {
return (
<>
<ItemSection />
<SidebarFooter />
</>
)
}
export default ChatPage

View File

@ -0,0 +1,30 @@
import { MainLayout, MainHeaderBar } from '@/layouts'
import { FilterContainer, ContentContainer } from '@/features/list'
import { SidebarFooter } from '@/features/auth'
export function ListPage() {
return (
<MainLayout
sidebarWidth="list"
sidebar={<Sidebar />}
header={
<MainHeaderBar align="center" className="px-[81px]">
<span className="text-xl font-bold whitespace-nowrap"> </span>
</MainHeaderBar>
}
>
<ContentContainer />
</MainLayout>
)
}
function Sidebar() {
return (
<>
<FilterContainer />
<SidebarFooter />
</>
)
}
export default ListPage

View File

@ -0,0 +1,21 @@
import { Logo } from '@/components'
import { LoginForm } from '@/features/auth'
export function LoginPage() {
return (
<main className="flex min-h-svh w-full items-center justify-center px-6">
<div className="flex w-full max-w-100 flex-col items-center gap-10">
<header className="flex flex-col items-center justify-center gap-4">
<Logo size="md" />
<h1 className="text-2xl font-bold leading-[30px] tracking-[-0.48px]">
</h1>
</header>
<LoginForm />
</div>
</main>
)
}
export default LoginPage

12
front/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 현재 실행 환경 식별용 (local | dev | prod) */
readonly VITE_APP_ENV: 'local' | 'dev' | 'prod'
/** 백엔드 API 베이스 URL */
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

30
front/tsconfig.app.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Path alias */
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
front/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
front/tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

14
front/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})