diff --git a/frontend/src/apis/chat/chat.api.ts b/frontend/src/apis/chat/chat.api.ts new file mode 100644 index 0000000..e039f3c --- /dev/null +++ b/frontend/src/apis/chat/chat.api.ts @@ -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 => { + const res = await http.get(`/v1/negotiation/sessions/${sessionId}/chat/init`) + return res.data + }, + + /** GET .../chat/messages — 대화 히스토리(seq 오름차순). 비어 있으면 오프닝 포함 */ + getMessages: async (sessionId: string): Promise => { + const res = await http.get( + `/v1/negotiation/sessions/${sessionId}/chat/messages`, + ) + return res.data + }, + + /** POST .../chat/send — 한 턴 전송, 새 봇 메시지 1건 반환(append-only) */ + send: async (sessionId: string, body: ChatSendRequest): Promise => { + const res = await http.post( + `/v1/negotiation/sessions/${sessionId}/chat/send`, + body, + ) + return res.data + }, +} diff --git a/frontend/src/apis/chat/chat.keys.ts b/frontend/src/apis/chat/chat.keys.ts new file mode 100644 index 0000000..fda0a83 --- /dev/null +++ b/frontend/src/apis/chat/chat.keys.ts @@ -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, +} diff --git a/frontend/src/apis/chat/chat.mutations.ts b/frontend/src/apis/chat/chat.mutations.ts new file mode 100644 index 0000000..fc4f439 --- /dev/null +++ b/frontend/src/apis/chat/chat.mutations.ts @@ -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), + }) +} diff --git a/frontend/src/apis/chat/chat.queries.ts b/frontend/src/apis/chat/chat.queries.ts new file mode 100644 index 0000000..824dd9e --- /dev/null +++ b/frontend/src/apis/chat/chat.queries.ts @@ -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, + }) +} diff --git a/frontend/src/apis/chat/chat.type.ts b/frontend/src/apis/chat/chat.type.ts new file mode 100644 index 0000000..dc6227b --- /dev/null +++ b/frontend/src/apis/chat/chat.type.ts @@ -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 ?? '', + } +} diff --git a/frontend/src/apis/chat/index.ts b/frontend/src/apis/chat/index.ts new file mode 100644 index 0000000..2544c3a --- /dev/null +++ b/frontend/src/apis/chat/index.ts @@ -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' diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/index.ts index e707984..3985e59 100644 --- a/frontend/src/apis/index.ts +++ b/frontend/src/apis/index.ts @@ -6,3 +6,4 @@ export type { ApiResult, ApiEnvelope } from './types' export * from './auth' export * from './negotiation' +export * from './chat' diff --git a/frontend/src/apis/types.ts b/frontend/src/apis/types.ts index 0115bf8..86d18ed 100644 --- a/frontend/src/apis/types.ts +++ b/frontend/src/apis/types.ts @@ -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 = { [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 로 담는다 */ diff --git a/frontend/src/features/chat/containers/ChatContainer.tsx b/frontend/src/features/chat/containers/ChatContainer.tsx index adeb3b6..1159b38 100644 --- a/frontend/src/features/chat/containers/ChatContainer.tsx +++ b/frontend/src/features/chat/containers/ChatContainer.tsx @@ -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 (
diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts new file mode 100644 index 0000000..ae9f320 --- /dev/null +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -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([ + 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, + } +} diff --git a/frontend/src/features/chat/hooks/useChatInit.ts b/frontend/src/features/chat/hooks/useChatInit.ts deleted file mode 100644 index c87a008..0000000 --- a/frontend/src/features/chat/hooks/useChatInit.ts +++ /dev/null @@ -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]) -} diff --git a/frontend/src/features/chat/lib/remainingTime.ts b/frontend/src/features/chat/lib/remainingTime.ts index f483a78..ad383c8 100644 --- a/frontend/src/features/chat/lib/remainingTime.ts +++ b/frontend/src/features/chat/lib/remainingTime.ts @@ -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() diff --git a/frontend/src/features/chat/mocks/mockChatInit.ts b/frontend/src/features/chat/mocks/mockChatInit.ts deleted file mode 100644 index a9840fb..0000000 --- a/frontend/src/features/chat/mocks/mockChatInit.ts +++ /dev/null @@ -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, -} diff --git a/frontend/src/features/chat/mocks/mockMessages.ts b/frontend/src/features/chat/mocks/mockMessages.ts deleted file mode 100644 index 34a299b..0000000 --- a/frontend/src/features/chat/mocks/mockMessages.ts +++ /dev/null @@ -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: '가격협상', - }, -] diff --git a/frontend/src/features/chat/stores/useChatStore.ts b/frontend/src/features/chat/stores/useChatStore.ts index 18ec603..c4b8b18 100644 --- a/frontend/src/features/chat/stores/useChatStore.ts +++ b/frontend/src/features/chat/stores/useChatStore.ts @@ -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((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((set, get) => ({ + ...initial, + + setSessionId: (sessionId) => set({ sessionId }), setMessages: (messages) => set((s) => ({ @@ -27,6 +42,12 @@ export const useChatStore = create((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((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 }) }, })) diff --git a/frontend/src/features/list/lib/datetime.ts b/frontend/src/features/list/lib/datetime.ts index 80da812..9e821f9 100644 --- a/frontend/src/features/list/lib/datetime.ts +++ b/frontend/src/features/list/lib/datetime.ts @@ -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) } diff --git a/frontend/src/lib/datetime.ts b/frontend/src/lib/datetime.ts new file mode 100644 index 0000000..2f5701e --- /dev/null +++ b/frontend/src/lib/datetime.ts @@ -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 | 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}` +} diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index b8bb278..f3d7612 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -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' diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index ace7989..3a690ca 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -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 + return ( } > - + ) }