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:
민헌 2026-06-18 14:25:07 +09:00
parent dcce82a630
commit ce536b43d6
19 changed files with 406 additions and 184 deletions

View 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
},
}

View 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,
}

View 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),
})
}

View 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,
})
}

View 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 ?? '',
}
}

View 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'

View File

@ -6,3 +6,4 @@ export type { ApiResult, ApiEnvelope } from './types'
export * from './auth'
export * from './negotiation'
export * from './chat'

View File

@ -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 로 담는다 */

View File

@ -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 />

View 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,
}
}

View File

@ -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])
}

View File

@ -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()

View File

@ -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,
}

View File

@ -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: '가격협상',
},
]

View File

@ -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 })
},
}))

View File

@ -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)
}

View 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}`
}

View File

@ -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'

View File

@ -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>
)
}