Merge pull request '프론트엔드: 구조 정비 + 협상 채팅 페이지 (mock UI)' (#2) from feature/chat into main
Reviewed-on: Negosium/o2o-negosium#2
This commit is contained in:
commit
7e2d5e5978
11
front/.env.sample
Normal file
11
front/.env.sample
Normal 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
29
front/.gitignore
vendored
Normal 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?
|
||||
@ -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
22
front/eslint.config.js
Normal 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
13
front/index.html
Normal 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
3614
front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
front/package.json
Normal file
43
front/package.json
Normal 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
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
24
front/public/icons.svg
Normal 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
16
front/src/App.tsx
Normal 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
|
||||
BIN
front/src/assets/imarketkorea-logo-white.png
Normal file
BIN
front/src/assets/imarketkorea-logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
front/src/assets/imarketkorea-logo.png
Normal file
BIN
front/src/assets/imarketkorea-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
52
front/src/components/Button.tsx
Normal file
52
front/src/components/Button.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
18
front/src/components/Input.tsx
Normal file
18
front/src/components/Input.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
53
front/src/components/Logo.tsx
Normal file
53
front/src/components/Logo.tsx
Normal 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
|
||||
5
front/src/components/index.ts
Normal file
5
front/src/components/index.ts
Normal 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'
|
||||
16
front/src/core/provider.tsx
Normal file
16
front/src/core/provider.tsx
Normal 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>
|
||||
}
|
||||
60
front/src/features/auth/components/LoginForm.tsx
Normal file
60
front/src/features/auth/components/LoginForm.tsx
Normal 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
|
||||
21
front/src/features/auth/components/SidebarFooter.tsx
Normal file
21
front/src/features/auth/components/SidebarFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
front/src/features/auth/hooks/useLogin.ts
Normal file
55
front/src/features/auth/hooks/useLogin.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
13
front/src/features/auth/hooks/useLoginMutation.ts
Normal file
13
front/src/features/auth/hooks/useLoginMutation.ts
Normal 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 () => {},
|
||||
})
|
||||
}
|
||||
2
front/src/features/auth/index.ts
Normal file
2
front/src/features/auth/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { LoginForm } from '@/features/auth/components/LoginForm'
|
||||
export { SidebarFooter } from '@/features/auth/components/SidebarFooter'
|
||||
115
front/src/features/chat/components/ChatMessage.tsx
Normal file
115
front/src/features/chat/components/ChatMessage.tsx
Normal 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]" />
|
||||
</>
|
||||
)
|
||||
})
|
||||
13
front/src/features/chat/components/ChatSection.tsx
Normal file
13
front/src/features/chat/components/ChatSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
front/src/features/chat/components/ItemImage.tsx
Normal file
74
front/src/features/chat/components/ItemImage.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
128
front/src/features/chat/components/ItemSection.tsx
Normal file
128
front/src/features/chat/components/ItemSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
front/src/features/chat/components/RemainingTime.tsx
Normal file
18
front/src/features/chat/components/RemainingTime.tsx
Normal 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>
|
||||
}
|
||||
99
front/src/features/chat/components/UserButton.tsx
Normal file
99
front/src/features/chat/components/UserButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
front/src/features/chat/components/menu/Contact.tsx
Normal file
22
front/src/features/chat/components/menu/Contact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
front/src/features/chat/components/menu/Guide.tsx
Normal file
18
front/src/features/chat/components/menu/Guide.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
front/src/features/chat/components/menu/MDInformation.tsx
Normal file
84
front/src/features/chat/components/menu/MDInformation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
front/src/features/chat/components/menu/MenuSection.tsx
Normal file
21
front/src/features/chat/components/menu/MenuSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
front/src/features/chat/components/menu/NegoStep.tsx
Normal file
52
front/src/features/chat/components/menu/NegoStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
front/src/features/chat/components/templates/BidSummary.tsx
Normal file
61
front/src/features/chat/components/templates/BidSummary.tsx
Normal 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"> {value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BidPrice({ number, isVAT, title }: { number: number; isVAT: boolean; title: string }) {
|
||||
const numberString = number.toLocaleString()
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Dot />
|
||||
<div className="flex flex-1">
|
||||
<div className="body-1-read-b flex flex-shrink-0">{title} :</div>
|
||||
<div className="flex flex-1 flex-wrap">
|
||||
<span className="body-1-read-b text-negative break-keep"> {numberString}원</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep">
|
||||
({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원)
|
||||
</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep"> {isVAT ? 'VAT포함' : 'VAT별도'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Dot() {
|
||||
return (
|
||||
<div className="flex justify-end items-center w-[26px] h-[30px]">
|
||||
<p className="body-1-read-r">•</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
front/src/features/chat/components/templates/Indicator.tsx
Normal file
78
front/src/features/chat/components/templates/Indicator.tsx
Normal 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">▼ 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>
|
||||
)
|
||||
}
|
||||
84
front/src/features/chat/components/templates/OtherReason.tsx
Normal file
84
front/src/features/chat/components/templates/OtherReason.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
front/src/features/chat/components/templates/RejectCM.tsx
Normal file
115
front/src/features/chat/components/templates/RejectCM.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
front/src/features/chat/components/templates/RejectRSP.tsx
Normal file
155
front/src/features/chat/components/templates/RejectRSP.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
front/src/features/chat/components/templates/Summary.tsx
Normal file
91
front/src/features/chat/components/templates/Summary.tsx
Normal 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} : </span>
|
||||
<span className="body-1-read-r">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PriceText({ price, isVAT }: { price: number; isVAT: boolean }) {
|
||||
const numberString = price.toLocaleString()
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Dot />
|
||||
<div className="flex flex-1">
|
||||
<div className="body-1-read-b flex flex-shrink-0">최종 협의 가격 :</div>
|
||||
<div className="flex flex-1 flex-wrap">
|
||||
<span className="body-1-read-b text-negative break-keep"> {numberString}원</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep">
|
||||
({numberToKorean(parseInt(numberString.replace(/,/g, '')))}원)
|
||||
</span>
|
||||
<span className="body-1-read-b text-neutral-90 break-keep"> {isVAT ? 'VAT포함' : 'VAT별도'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Dot() {
|
||||
return (
|
||||
<div className="flex justify-end items-center w-[26px] h-[30px]">
|
||||
<p className="body-1-read-r">•</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
157
front/src/features/chat/components/userInputs.tsx
Normal file
157
front/src/features/chat/components/userInputs.tsx
Normal 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="가격 입력"
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
front/src/features/chat/containers/ChatContainer.tsx
Normal file
14
front/src/features/chat/containers/ChatContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
front/src/features/chat/hooks/useChatInit.ts
Normal file
16
front/src/features/chat/hooks/useChatInit.ts
Normal 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])
|
||||
}
|
||||
4
front/src/features/chat/index.ts
Normal file
4
front/src/features/chat/index.ts
Normal 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'
|
||||
45
front/src/features/chat/lib/koreanNumber.ts
Normal file
45
front/src/features/chat/lib/koreanNumber.ts
Normal 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
|
||||
}
|
||||
24
front/src/features/chat/lib/rejectForm.ts
Normal file
24
front/src/features/chat/lib/rejectForm.ts
Normal 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: '' }
|
||||
}
|
||||
15
front/src/features/chat/lib/remainingTime.ts
Normal file
15
front/src/features/chat/lib/remainingTime.ts
Normal 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)}초`
|
||||
}
|
||||
25
front/src/features/chat/lib/userButtonConfig.ts
Normal file
25
front/src/features/chat/lib/userButtonConfig.ts
Normal 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: '' }
|
||||
}
|
||||
25
front/src/features/chat/mocks/mockChatInit.ts
Normal file
25
front/src/features/chat/mocks/mockChatInit.ts
Normal 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,
|
||||
}
|
||||
106
front/src/features/chat/mocks/mockMessages.ts
Normal file
106
front/src/features/chat/mocks/mockMessages.ts
Normal 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: '가격협상',
|
||||
},
|
||||
]
|
||||
32
front/src/features/chat/stores/useChatInitStore.ts
Normal file
32
front/src/features/chat/stores/useChatInitStore.ts
Normal 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),
|
||||
}))
|
||||
63
front/src/features/chat/stores/useChatStore.ts
Normal file
63
front/src/features/chat/stores/useChatStore.ts
Normal 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, '') })
|
||||
},
|
||||
}))
|
||||
82
front/src/features/chat/types.ts
Normal file
82
front/src/features/chat/types.ts
Normal 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
|
||||
}
|
||||
24
front/src/features/list/components/ActionSection.tsx
Normal file
24
front/src/features/list/components/ActionSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
front/src/features/list/components/FilterGroup.tsx
Normal file
69
front/src/features/list/components/FilterGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
front/src/features/list/components/Pagination.tsx
Normal file
102
front/src/features/list/components/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
front/src/features/list/components/TableSection.tsx
Normal file
79
front/src/features/list/components/TableSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
front/src/features/list/components/paginationIcons.tsx
Normal file
32
front/src/features/list/components/paginationIcons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
front/src/features/list/components/tableColumns.tsx
Normal file
45
front/src/features/list/components/tableColumns.tsx
Normal 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),
|
||||
},
|
||||
]
|
||||
27
front/src/features/list/containers/ContentContainer.tsx
Normal file
27
front/src/features/list/containers/ContentContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
front/src/features/list/containers/FilterContainer.tsx
Normal file
52
front/src/features/list/containers/FilterContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
front/src/features/list/filterOptions.ts
Normal file
17
front/src/features/list/filterOptions.ts
Normal 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: ['남은 시간 적은 순', '남은 시간 긴 순'] },
|
||||
]
|
||||
57
front/src/features/list/hooks/useList.ts
Normal file
57
front/src/features/list/hooks/useList.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
3
front/src/features/list/index.ts
Normal file
3
front/src/features/list/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// 공개 API (페이지에서 쓰는 것만)
|
||||
export { FilterContainer } from '@/features/list/containers/FilterContainer'
|
||||
export { ContentContainer } from '@/features/list/containers/ContentContainer'
|
||||
8
front/src/features/list/lib/datetime.ts
Normal file
8
front/src/features/list/lib/datetime.ts
Normal 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())}`
|
||||
}
|
||||
18
front/src/features/list/lib/pagination.ts
Normal file
18
front/src/features/list/lib/pagination.ts
Normal 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]
|
||||
}
|
||||
115
front/src/features/list/mocks/mockItems.ts
Normal file
115
front/src/features/list/mocks/mockItems.ts
Normal 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: '로지텍',
|
||||
},
|
||||
]
|
||||
33
front/src/features/list/stores/useListStore.ts
Normal file
33
front/src/features/list/stores/useListStore.ts
Normal 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 }),
|
||||
}))
|
||||
11
front/src/features/list/types.ts
Normal file
11
front/src/features/list/types.ts
Normal 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
181
front/src/index.css
Normal 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);
|
||||
}
|
||||
22
front/src/layouts/MainHeaderBar.tsx
Normal file
22
front/src/layouts/MainHeaderBar.tsx
Normal 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
|
||||
74
front/src/layouts/MainLayout.tsx
Normal file
74
front/src/layouts/MainLayout.tsx
Normal 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
|
||||
4
front/src/layouts/index.ts
Normal file
4
front/src/layouts/index.ts
Normal 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
6
front/src/lib/cn.ts
Normal 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
4
front/src/lib/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// 전역 순수 유틸/스타일
|
||||
export { cn } from '@/lib/cn'
|
||||
export type { ClassValue } from '@/lib/cn'
|
||||
export { interactive } from '@/lib/interactive'
|
||||
3
front/src/lib/interactive.ts
Normal file
3
front/src/lib/interactive.ts
Normal 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
13
front/src/main.tsx
Normal 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>,
|
||||
)
|
||||
49
front/src/pages/ChatPage.tsx
Normal file
49
front/src/pages/ChatPage.tsx
Normal 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
|
||||
30
front/src/pages/ListPage.tsx
Normal file
30
front/src/pages/ListPage.tsx
Normal 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
|
||||
21
front/src/pages/LoginPage.tsx
Normal file
21
front/src/pages/LoginPage.tsx
Normal 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
12
front/src/vite-env.d.ts
vendored
Normal 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
30
front/tsconfig.app.json
Normal 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
7
front/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
front/tsconfig.node.json
Normal file
24
front/tsconfig.node.json
Normal 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
14
front/vite.config.ts
Normal 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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user