feat(frontend): 채팅 페이지 API 연동 + KST 시각 표시
- apis/chat 모듈(api/queries/mutations/keys/type) + useChatController(append-only send 주입) - useChatStore 재배선, list→chat session_id 라우팅 연결, mock 제거 - 마감/종료/권한 시 입력 잠금·목록 복귀, 가격범위 인라인 에러, CHAT_IN_PROGRESS 처리 - list 마감일 KST(Asia/Seoul) 고정 표시(lib/datetime formatKstDateTime) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dcce82a630
commit
ce536b43d6
33
frontend/src/apis/chat/chat.api.ts
Normal file
33
frontend/src/apis/chat/chat.api.ts
Normal file
@ -0,0 +1,33 @@
|
||||
// 채팅 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
|
||||
import { http } from '@/apis/http'
|
||||
import type {
|
||||
ChatInitResponse,
|
||||
ChatMessagesResponse,
|
||||
ChatSendRequest,
|
||||
ChatSendResponse,
|
||||
} from './chat.type'
|
||||
|
||||
export const chatApi = {
|
||||
/** GET .../chat/init — 상품·견적 메타 + 세션 상태 + 마감 시각 */
|
||||
getInit: async (sessionId: string): Promise<ChatInitResponse> => {
|
||||
const res = await http.get<ChatInitResponse>(`/v1/negotiation/sessions/${sessionId}/chat/init`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** GET .../chat/messages — 대화 히스토리(seq 오름차순). 비어 있으면 오프닝 포함 */
|
||||
getMessages: async (sessionId: string): Promise<ChatMessagesResponse> => {
|
||||
const res = await http.get<ChatMessagesResponse>(
|
||||
`/v1/negotiation/sessions/${sessionId}/chat/messages`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** POST .../chat/send — 한 턴 전송, 새 봇 메시지 1건 반환(append-only) */
|
||||
send: async (sessionId: string, body: ChatSendRequest): Promise<ChatSendResponse> => {
|
||||
const res = await http.post<ChatSendResponse>(
|
||||
`/v1/negotiation/sessions/${sessionId}/chat/send`,
|
||||
body,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
6
frontend/src/apis/chat/chat.keys.ts
Normal file
6
frontend/src/apis/chat/chat.keys.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// 채팅 도메인의 TanStack Query 키 팩토리.
|
||||
export const chatKeys = {
|
||||
all: ['chat'] as const,
|
||||
init: (sessionId: string) => [...chatKeys.all, 'init', sessionId] as const,
|
||||
messages: (sessionId: string) => [...chatKeys.all, 'messages', sessionId] as const,
|
||||
}
|
||||
14
frontend/src/apis/chat/chat.mutations.ts
Normal file
14
frontend/src/apis/chat/chat.mutations.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// 채팅 도메인의 변경(useMutation) 훅.
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { chatApi } from './chat.api'
|
||||
import type { ChatSendRequest } from './chat.type'
|
||||
|
||||
/**
|
||||
* 협상 한 턴 전송. append-only 라 캐시 무효화/refetch 를 하지 않는다.
|
||||
* 응답의 새 봇 메시지는 호출부(컨트롤러)가 스토어에 직접 append 한다.
|
||||
*/
|
||||
export function useChatSendMutation(sessionId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (body: ChatSendRequest) => chatApi.send(sessionId, body),
|
||||
})
|
||||
}
|
||||
29
frontend/src/apis/chat/chat.queries.ts
Normal file
29
frontend/src/apis/chat/chat.queries.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// 채팅 도메인의 조회(useQuery) 훅.
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { chatApi } from './chat.api'
|
||||
import { chatKeys } from './chat.keys'
|
||||
import { mapInit, mapMessage } from './chat.type'
|
||||
|
||||
/** 채팅 진입 메타(상품·견적). 마감 시각은 거의 불변이라 오래 캐싱한다. */
|
||||
export function useChatInitQuery(sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.init(sessionId),
|
||||
queryFn: () => chatApi.getInit(sessionId).then(mapInit),
|
||||
enabled: !!sessionId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화 히스토리. 진입 시 1회만 받고 이후엔 send 응답을 로컬에 append 한다(append-only).
|
||||
* 따라서 백그라운드 refetch 가 로컬 상태를 덮지 않도록 staleTime 을 무한대로 둔다.
|
||||
*/
|
||||
export function useChatMessagesQuery(sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.messages(sessionId),
|
||||
queryFn: () => chatApi.getMessages(sessionId).then((r) => r.items.map(mapMessage)),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
}
|
||||
103
frontend/src/apis/chat/chat.type.ts
Normal file
103
frontend/src/apis/chat/chat.type.ts
Normal file
@ -0,0 +1,103 @@
|
||||
// 채팅 API 의 와이어 타입(백엔드 snake_case 미러) + feature 타입 매퍼.
|
||||
// sender 는 정수 코드(1=BOT, 2=USER)로 내려오고 프론트에서 'bot'|'user' 로 매핑한다.
|
||||
import type { ApiResult } from '@/apis/types'
|
||||
import type {
|
||||
ChatInitData,
|
||||
ChatMessage,
|
||||
NextInputMode,
|
||||
UserInputType,
|
||||
} from '@/features/chat/types'
|
||||
|
||||
// RemoveNoneResponse 로 null 필드는 생략될 수 있어 대부분 optional.
|
||||
export interface ChatMessageWire {
|
||||
chat_id: string
|
||||
session_id: string
|
||||
seq: number
|
||||
sender: number
|
||||
script?: string
|
||||
user_input_type?: string | null
|
||||
step?: string
|
||||
display_step?: string
|
||||
next_input_mode?: string | null
|
||||
next_input_type?: string[] | null
|
||||
chat_end?: boolean
|
||||
indicator_value?: number | null
|
||||
bot_chat_type?: string | null
|
||||
}
|
||||
|
||||
export interface ChatInitResponse {
|
||||
result: ApiResult
|
||||
session_id: string
|
||||
session_status: number
|
||||
quotation_id: string
|
||||
quotation_end_time: string
|
||||
quotation_memo?: string
|
||||
item_id: string
|
||||
item_name: string
|
||||
item_code?: string
|
||||
item_image?: string
|
||||
item_price: number
|
||||
item_model_name?: string
|
||||
item_maker_name?: string
|
||||
item_spec?: string
|
||||
item_lead_time?: string
|
||||
item_min_order_quantity?: string
|
||||
item_vat_yn?: boolean
|
||||
item_delivery_fee_yn?: boolean
|
||||
}
|
||||
|
||||
export interface ChatMessagesResponse {
|
||||
result: ApiResult
|
||||
items: ChatMessageWire[]
|
||||
}
|
||||
|
||||
export interface ChatSendRequest {
|
||||
user_input: string
|
||||
user_input_type?: string | null
|
||||
}
|
||||
|
||||
export interface ChatSendResponse {
|
||||
result: ApiResult
|
||||
message?: ChatMessageWire
|
||||
session_status: number
|
||||
}
|
||||
|
||||
// --- 매퍼: 와이어 → feature 타입 --------------------------------------------
|
||||
export function mapMessage(w: ChatMessageWire): ChatMessage {
|
||||
return {
|
||||
chat_id: w.chat_id,
|
||||
sender: w.sender === 1 ? 'bot' : 'user',
|
||||
bot_chat_type: (w.bot_chat_type ?? null) as ChatMessage['bot_chat_type'],
|
||||
user_input_type: (w.user_input_type ?? null) as UserInputType | null,
|
||||
script: w.script ?? null,
|
||||
chat_end: w.chat_end ?? false,
|
||||
next_input_mode: (w.next_input_mode ?? null) as NextInputMode | null,
|
||||
next_input_type: w.next_input_type ?? null,
|
||||
step: w.step ?? '',
|
||||
display_step: w.display_step ?? '',
|
||||
summary: null, // (범위 외) 최종 요약 카드는 추후
|
||||
indicator_value: w.indicator_value ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapInit(r: ChatInitResponse): ChatInitData {
|
||||
return {
|
||||
session_id: r.session_id,
|
||||
item_id: r.item_id,
|
||||
quotation_id: r.quotation_id,
|
||||
item_name: r.item_name,
|
||||
item_code: r.item_code ?? '',
|
||||
item_image: r.item_image ?? '',
|
||||
item_price: r.item_price ?? 0,
|
||||
item_model_name: r.item_model_name ?? '',
|
||||
item_maker_name: r.item_maker_name ?? '',
|
||||
item_vat_yn: r.item_vat_yn == null ? '' : r.item_vat_yn ? 'VAT포함' : 'VAT별도',
|
||||
item_delivery_fee_yn:
|
||||
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
|
||||
item_min_order_quantity: r.item_min_order_quantity ?? '',
|
||||
item_lead_time: r.item_lead_time ?? '',
|
||||
item_spec: r.item_spec ?? '',
|
||||
quotation_memo: r.quotation_memo ?? '',
|
||||
quotation_end_time: r.quotation_end_time ?? '',
|
||||
}
|
||||
}
|
||||
13
frontend/src/apis/chat/index.ts
Normal file
13
frontend/src/apis/chat/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// 채팅 도메인 API 공개 표면.
|
||||
export { chatApi } from './chat.api'
|
||||
export { chatKeys } from './chat.keys'
|
||||
export { useChatInitQuery, useChatMessagesQuery } from './chat.queries'
|
||||
export { useChatSendMutation } from './chat.mutations'
|
||||
export { mapInit, mapMessage } from './chat.type'
|
||||
export type {
|
||||
ChatInitResponse,
|
||||
ChatMessagesResponse,
|
||||
ChatMessageWire,
|
||||
ChatSendRequest,
|
||||
ChatSendResponse,
|
||||
} from './chat.type'
|
||||
@ -6,3 +6,4 @@ export type { ApiResult, ApiEnvelope } from './types'
|
||||
|
||||
export * from './auth'
|
||||
export * from './negotiation'
|
||||
export * from './chat'
|
||||
|
||||
@ -37,6 +37,10 @@ export const ErrorCode = {
|
||||
NEGO_QUOTATION_CLOSED: 1302,
|
||||
NEGO_DEADLINE_PASSED: 1303,
|
||||
NEGO_NOT_FOUND: 1304,
|
||||
CHAT_NOT_IN_PROGRESS: 1400,
|
||||
CHAT_PRICE_OUT_OF_RANGE: 1401,
|
||||
CHAT_AGENT_UNAVAILABLE: 1402,
|
||||
CHAT_IN_PROGRESS: 1403,
|
||||
} as const
|
||||
|
||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
|
||||
@ -54,6 +58,10 @@ const API_ERROR_MESSAGES: Record<number, string> = {
|
||||
[ErrorCode.NEGO_QUOTATION_CLOSED]: '마감된 견적입니다.',
|
||||
[ErrorCode.NEGO_DEADLINE_PASSED]: '협상 마감 시간이 지났습니다.',
|
||||
[ErrorCode.NEGO_NOT_FOUND]: '협상을 찾을 수 없습니다.',
|
||||
[ErrorCode.CHAT_NOT_IN_PROGRESS]: '진행 중인 협상이 아닙니다. 목록으로 돌아갑니다.',
|
||||
[ErrorCode.CHAT_PRICE_OUT_OF_RANGE]: '제시 가격이 허용 범위를 벗어났습니다.',
|
||||
[ErrorCode.CHAT_AGENT_UNAVAILABLE]: '협상 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||
[ErrorCode.CHAT_IN_PROGRESS]: '이전 메시지를 처리 중입니다. 잠시만 기다려주세요.',
|
||||
}
|
||||
|
||||
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useChatInit } from '@/features/chat/hooks/useChatInit'
|
||||
import { useChatController } from '@/features/chat/hooks/useChatController'
|
||||
import { ChatSection } from '@/features/chat/components/ChatSection'
|
||||
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
|
||||
|
||||
// 콘텐츠 영역: 채팅 + 우측 메뉴. mock 데이터를 스토어에 적재한다.
|
||||
export function ChatContainer() {
|
||||
useChatInit()
|
||||
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
|
||||
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
||||
useChatController(sessionId)
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 w-full">
|
||||
<ChatSection />
|
||||
|
||||
113
frontend/src/features/chat/hooks/useChatController.ts
Normal file
113
frontend/src/features/chat/hooks/useChatController.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useChatInitQuery, useChatMessagesQuery, useChatSendMutation, mapMessage } from '@/apis/chat'
|
||||
import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types'
|
||||
import { toast } from '@/lib'
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import type { ChatMessage, UserInputType } from '@/features/chat/types'
|
||||
|
||||
// 더 이상 대화를 이어갈 수 없는(세션 종료/마감/권한) 코드 → 입력 잠금 후 목록으로 복귀.
|
||||
const TERMINAL_CODES = new Set<number>([
|
||||
ErrorCode.CHAT_NOT_IN_PROGRESS,
|
||||
ErrorCode.NEGO_QUOTATION_CLOSED,
|
||||
ErrorCode.NEGO_DEADLINE_PASSED,
|
||||
ErrorCode.NEGO_FORBIDDEN,
|
||||
ErrorCode.NEGO_NOT_FOUND,
|
||||
])
|
||||
|
||||
let tempSeq = 0
|
||||
|
||||
// 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다.
|
||||
function makeUserMessage(text: string, inputType: UserInputType): ChatMessage {
|
||||
return {
|
||||
chat_id: `temp-user-${tempSeq++}`,
|
||||
sender: 'user',
|
||||
bot_chat_type: null,
|
||||
user_input_type: inputType,
|
||||
script: text,
|
||||
chat_end: false,
|
||||
next_input_mode: null,
|
||||
next_input_type: null,
|
||||
step: '',
|
||||
display_step: '',
|
||||
summary: null,
|
||||
indicator_value: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅 페이지 컨트롤러: init/messages 조회를 스토어에 적재하고,
|
||||
* append-only 전송 구현을 스토어에 주입한다. (mock 을 대체)
|
||||
*/
|
||||
export function useChatController(sessionId: string) {
|
||||
const navigate = useNavigate()
|
||||
const setInitData = useChatInitStore((s) => s.setInitData)
|
||||
const initQuery = useChatInitQuery(sessionId)
|
||||
const messagesQuery = useChatMessagesQuery(sessionId)
|
||||
const sendMutation = useChatSendMutation(sessionId)
|
||||
|
||||
// init 메타 → 스토어
|
||||
useEffect(() => {
|
||||
if (initQuery.data) setInitData(initQuery.data)
|
||||
}, [initQuery.data, setInitData])
|
||||
|
||||
// 대화 히스토리 → 스토어 (진입 1회)
|
||||
useEffect(() => {
|
||||
if (messagesQuery.data) useChatStore.getState().setMessages(messagesQuery.data)
|
||||
}, [messagesQuery.data])
|
||||
|
||||
// sessionId + 전송 구현 주입
|
||||
useEffect(() => {
|
||||
const store = useChatStore.getState()
|
||||
store.setSessionId(sessionId)
|
||||
|
||||
store.bindSend((text, inputType = 'text') => {
|
||||
const optimistic = makeUserMessage(text, inputType)
|
||||
store.appendMessage(optimistic)
|
||||
store.setIsLoading(true)
|
||||
store.setPriceErrorMessage('')
|
||||
|
||||
sendMutation.mutate(
|
||||
{ user_input: text, user_input_type: inputType === 'text' ? null : inputType },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const s = useChatStore.getState()
|
||||
if (data.message) s.appendMessage(mapMessage(data.message))
|
||||
s.setIsLoading(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
const s = useChatStore.getState()
|
||||
// 낙관적 메시지 롤백 (서버에 저장되지 않음)
|
||||
s.setMessages(s.messages.filter((m) => m.chat_id !== optimistic.chat_id))
|
||||
s.setIsLoading(false)
|
||||
const code = isApiError(error) ? error.code : 0
|
||||
|
||||
// 가격 범위 초과: 입력창 인라인 에러로 표시(페이지 유지)
|
||||
if (code === ErrorCode.CHAT_PRICE_OUT_OF_RANGE) {
|
||||
s.setPriceErrorMessage(getApiErrorMessage(error))
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.'))
|
||||
|
||||
// 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀
|
||||
if (TERMINAL_CODES.has(code)) {
|
||||
s.bindSend(null) // 입력 잠금(추가 전송 차단)
|
||||
setTimeout(() => navigate('/list'), 1500)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
useChatStore.getState().reset()
|
||||
}
|
||||
}, [sessionId, sendMutation, navigate])
|
||||
|
||||
return {
|
||||
isInitLoading: initQuery.isLoading || messagesQuery.isLoading,
|
||||
initError: initQuery.error ?? messagesQuery.error,
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
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])
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
// 마감까지 남은 시간 'HH시간 MM분 SS초'. 지났으면 종료 문구, 잘못된 값은 '-'.
|
||||
// 남은 시간은 "절대 시각 간 차이(duration)"라 타임존과 무관하다. 백엔드가 UTC(+00:00 오프셋 포함)로
|
||||
// 내려주므로 new Date 파싱이 정확하면 KST 변환이 따로 필요 없다(날짜로 "표시"할 때만 KST 고정 필요 → lib/datetime).
|
||||
export function getTimeRemaining(targetDateTime: string | Date): string {
|
||||
const now = Date.now()
|
||||
const target = new Date(targetDateTime).getTime()
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
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: '가격협상',
|
||||
},
|
||||
]
|
||||
@ -2,24 +2,39 @@ import { create } from 'zustand'
|
||||
import type { ChatMessage, UserButtonConfig, UserInputType } from '@/features/chat/types'
|
||||
import { deriveUserButtonConfig } from '@/features/chat/lib/userButtonConfig'
|
||||
|
||||
type SendFn = (text: string, inputType?: UserInputType) => void
|
||||
|
||||
type ChatStore = {
|
||||
sessionId: string
|
||||
messages: ChatMessage[]
|
||||
userButtonConfig: UserButtonConfig
|
||||
isLoading: boolean
|
||||
priceErrorMessage: string
|
||||
setSessionId: (sessionId: string) => void
|
||||
setMessages: (messages: ChatMessage[]) => void
|
||||
appendMessage: (message: ChatMessage) => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setPriceErrorMessage: (message: string) => void
|
||||
sendMessage: (text: string, inputType?: UserInputType) => void
|
||||
// 실제 전송 구현은 컨트롤러(useChatController)가 React Query 와 함께 주입한다.
|
||||
bindSend: (fn: SendFn | null) => void
|
||||
sendMessage: SendFn
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
let mockSeq = 0
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
messages: [],
|
||||
userButtonConfig: { type: '', text: '' },
|
||||
const initial = {
|
||||
sessionId: '',
|
||||
messages: [] as ChatMessage[],
|
||||
userButtonConfig: { type: '' } as UserButtonConfig,
|
||||
isLoading: false,
|
||||
priceErrorMessage: '',
|
||||
}
|
||||
|
||||
let sendImpl: SendFn | null = null
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
...initial,
|
||||
|
||||
setSessionId: (sessionId) => set({ sessionId }),
|
||||
|
||||
setMessages: (messages) =>
|
||||
set((s) => ({
|
||||
@ -27,6 +42,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
userButtonConfig: deriveUserButtonConfig(messages, s.isLoading, s.priceErrorMessage),
|
||||
})),
|
||||
|
||||
appendMessage: (message) =>
|
||||
set((s) => {
|
||||
const messages = [...s.messages, message]
|
||||
return { messages, userButtonConfig: deriveUserButtonConfig(messages, s.isLoading, s.priceErrorMessage) }
|
||||
}),
|
||||
|
||||
setIsLoading: (isLoading) =>
|
||||
set((s) => ({
|
||||
isLoading,
|
||||
@ -39,25 +60,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
userButtonConfig: deriveUserButtonConfig(s.messages, s.isLoading, priceErrorMessage),
|
||||
})),
|
||||
|
||||
// mock: API 미연동이라 사용자 메시지를 로컬에 추가만 한다 (실제 협상 진행 로직 없음)
|
||||
bindSend: (fn) => {
|
||||
sendImpl = fn
|
||||
},
|
||||
|
||||
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, '') })
|
||||
if (get().isLoading) return
|
||||
sendImpl?.(text, inputType)
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
sendImpl = null
|
||||
set({ ...initial })
|
||||
},
|
||||
}))
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
// 'YYYY-MM-DD HH:mm'. 빈 값 '-', 파싱 실패 시 원본.
|
||||
import { formatKstDateTime } from '@/lib'
|
||||
|
||||
// 'YYYY-MM-DD HH:mm' (KST 고정). 백엔드 UTC 직렬화를 한국 시간으로 표시한다.
|
||||
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())}`
|
||||
return formatKstDateTime(value)
|
||||
}
|
||||
|
||||
29
frontend/src/lib/datetime.ts
Normal file
29
frontend/src/lib/datetime.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// 시각 표시 유틸. 백엔드는 모든 시각을 UTC(+00:00 오프셋 포함 ISO)로 직렬화하므로,
|
||||
// 절대 시각 파싱은 브라우저 타임존과 무관하게 정확하다. 다만 "표시"는 항상 KST(Asia/Seoul)로
|
||||
// 고정해야 한국 외 타임존 브라우저에서도 동일한 마감 시각을 보여줄 수 있다.
|
||||
export const KST_TIME_ZONE = 'Asia/Seoul'
|
||||
|
||||
// hourCycle:'h23' → 자정을 24 가 아닌 00 으로(엔진별 '24:00' 방지). locale 무관 고정 포맷.
|
||||
const kstFormat = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
|
||||
function toKstParts(value: string | Date): Record<string, string> | null {
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
return Object.fromEntries(kstFormat.formatToParts(d).map((p) => [p.type, p.value]))
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD HH:mm' (KST). 빈 값 '-', 파싱 실패 시 원본 문자열. */
|
||||
export function formatKstDateTime(value: string | Date): string {
|
||||
if (!value) return '-'
|
||||
const p = toKstParts(value)
|
||||
if (!p) return typeof value === 'string' ? value : '-'
|
||||
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`
|
||||
}
|
||||
@ -3,3 +3,4 @@ export { cn } from '@/lib/cn'
|
||||
export type { ClassValue } from '@/lib/cn'
|
||||
export { interactive } from '@/lib/interactive'
|
||||
export { toast } from '@/lib/toast'
|
||||
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNavigate, useSearchParams, Navigate } from 'react-router'
|
||||
import { List } from 'lucide-react'
|
||||
import { cn, interactive } from '@/lib'
|
||||
import { MainLayout, MainHeaderBar } from '@/layouts'
|
||||
@ -6,6 +6,12 @@ import { ChatContainer, ItemSection, RemainingTime } from '@/features/chat'
|
||||
import { SidebarFooter } from '@/features/auth'
|
||||
|
||||
export function ChatPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const sessionId = searchParams.get('session_id') ?? ''
|
||||
|
||||
// session_id 없이 진입하면 목록으로 되돌린다.
|
||||
if (!sessionId) return <Navigate to="/list" replace />
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
sidebarWidth="chat"
|
||||
@ -18,7 +24,7 @@ export function ChatPage() {
|
||||
</MainHeaderBar>
|
||||
}
|
||||
>
|
||||
<ChatContainer />
|
||||
<ChatContainer sessionId={sessionId} />
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user