[fix] negosium·negodata·landing: 채팅 진입·부가정보 저장 오류 수정, 모바일 여백·스크롤·노치 대응
- 채팅 링크로 바로 진입하면 세션이 협상생성(1)에 머물러 오프닝 메시지가 seed 되지 않던 문제: chat init/messages 에서 participate 와 동일하게 협상중(2)으로 전이(마감 견적·마감시간 초과는 제외) - 부가정보 저장이 1301 로 실패하던 문제: '협상완료' 요약은 chat_end=false 라 세션이 아직 협상중이므로, 마지막 말풍선이 summaryRSP/CM 이면 협상중 세션도 저장 허용 - 채팅 언마운트 시 messages 캐시도 제거 — staleTime:Infinity 라 빈 목록이 남아 재진입해도 빈 화면이던 문제 - 채팅 스크롤을 컨테이너 기준 scrollTo 로 변경(rAF 후 실행) — 요약·폼이 뒤늦게 레이아웃되면 중간에 멈추던 문제 - 모바일 채팅 상단 여백 제거(pt-[64px] 는 우측 협상절차 카드가 있는 lg 이상만) - 노치 대응: viewport-fit=cover + safe-t/b/x 유틸 신설, 헤더·하단 액션 덱·드로어·랜딩 헤더에 적용, h-screen→100dvh
This commit is contained in:
parent
0f10b6a70f
commit
db07a60cfa
@ -209,6 +209,8 @@ class ChatService:
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||
return res
|
||||
|
||||
await self._ensure_in_progress(sess, quote)
|
||||
|
||||
res.session_id = str(sess.session_id)
|
||||
res.session_status = sess.status
|
||||
res.quotation_id = str(sess.quotation_id)
|
||||
@ -229,6 +231,33 @@ class ChatService:
|
||||
res.custom = sess.custom or {}
|
||||
return res
|
||||
|
||||
async def _ensure_in_progress(self, sess, quote) -> None:
|
||||
"""협상생성(1) 세션을 채팅 진입만으로 협상중(2)으로 전이한다(participate 와 동일 전이).
|
||||
|
||||
negodata 안내 메일/링크는 목록의 참여 버튼을 거치지 않고 chat 으로 바로 들어오는데,
|
||||
오프닝 메시지는 협상중일 때만 seed 되므로 전이가 없으면 빈 채팅으로 멈춘다.
|
||||
마감된 견적은 진입해도 대화가 불가하므로 전이하지 않는다."""
|
||||
if sess.status != SessionStatus.CREATED.value:
|
||||
return
|
||||
if quote is None or quote.status == QuotationStatus.CLOSED.value:
|
||||
return
|
||||
end = quote.end_time
|
||||
if end is not None and end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
if end is not None and end < datetime.now(timezone.utc):
|
||||
return
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[sessions.DBType()],
|
||||
[
|
||||
lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value),
|
||||
lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value),
|
||||
],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return
|
||||
sess.status = SessionStatus.IN_PROGRESS.value
|
||||
|
||||
# ---- messages -------------------------------------------------------
|
||||
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
|
||||
res = Res_ChatMessages()
|
||||
@ -245,6 +274,15 @@ class ChatService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 협상생성 상태로 바로 진입한 경우(메일 링크) 여기서도 전이한다 —
|
||||
# init 과 병렬로 호출돼 init 의 전이를 못 본 채 읽었을 수 있다.
|
||||
if not rows and sess.status == SessionStatus.CREATED.value:
|
||||
_, quote = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||
)
|
||||
await self._ensure_in_progress(sess, quote)
|
||||
|
||||
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
|
||||
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
|
||||
opening = await self._seed_opening(sess)
|
||||
|
||||
@ -4,9 +4,10 @@ from datetime import datetime, timezone
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import sessions
|
||||
from common.database.model.models import chats, sessions
|
||||
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||
from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
|
||||
from services.auth_service import AuthService
|
||||
@ -18,9 +19,18 @@ class NegotiationService:
|
||||
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
|
||||
"""
|
||||
|
||||
def __init__(self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD)):
|
||||
# 부가정보 입력 폼을 띄우는 요약 말풍선 종류. 이 말풍선이 나온 뒤면 협상은 타결된 것으로 본다.
|
||||
_SUMMARY_BOT_TYPES = ("summaryRSP", "summaryCM")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth: AuthService = Depends(AuthService),
|
||||
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
||||
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
||||
):
|
||||
self.auth = auth
|
||||
self.session_crud = session_crud
|
||||
self.chat_crud = chat_crud
|
||||
|
||||
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList:
|
||||
res = Res_SessionList()
|
||||
@ -104,10 +114,13 @@ class NegotiationService:
|
||||
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
|
||||
return res
|
||||
|
||||
# 3) 협상완료(타결) 세션만 부가정보 입력 허용
|
||||
# 3) 협상완료(타결) 세션만 부가정보 입력 허용.
|
||||
# 단 '협상완료' 요약 말풍선은 chat_end=false 라 세션이 아직 협상중(2)이다
|
||||
# (동의 → '협상종료' 턴에서야 완료로 전이). 폼은 요약 시점에 뜨므로 그 구간도 허용한다.
|
||||
if sess.status != SessionStatus.DONE.value:
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||
return res
|
||||
if sess.status != SessionStatus.IN_PROGRESS.value or not await self._is_after_summary(sess.session_id):
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||
return res
|
||||
|
||||
# 4) 저장(supplier_id 가드 crud)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
@ -121,6 +134,16 @@ class NegotiationService:
|
||||
res.session_id = str(session_id)
|
||||
return res
|
||||
|
||||
async def _is_after_summary(self, session_id) -> bool:
|
||||
"""마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지."""
|
||||
err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.get_last(s, session_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or not last_meta:
|
||||
return False
|
||||
return last_meta.get("bot_chat_type") in self._SUMMARY_BOT_TYPES
|
||||
|
||||
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
||||
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
||||
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/bot.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>AI 가격 협상 솔루션</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, memo } from 'react'
|
||||
import { useEffect, useRef, memo, type RefObject } from 'react'
|
||||
import { useMeQuery } from '@/apis'
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
@ -14,24 +14,40 @@ import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
||||
const AI_LABEL = '아이마켓코리아 (구매 MD)'
|
||||
|
||||
export function ChatMessage() {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
return (
|
||||
<div className="flex-1 w-full min-h-0">
|
||||
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
|
||||
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]">
|
||||
<ChatList />
|
||||
{/* lg:pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추는 값 — 그 카드가 없는 lg 미만에선
|
||||
스텝바 바로 아래 여백으로만 남아 첫 메시지가 위로 안 붙으므로 뺀다.
|
||||
스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="chat-scroll h-full overflow-y-auto flex flex-col pt-4 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
|
||||
>
|
||||
<ChatList scrollRef={scrollRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatList() {
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }) {
|
||||
const isFirstRef = useRef(true)
|
||||
const chats = useChatStore((s) => s.messages)
|
||||
const isLoading = useChatStore((s) => s.isLoading)
|
||||
|
||||
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤
|
||||
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤.
|
||||
// 스크롤 컨테이너를 직접 내린다 — scrollIntoView 는 요약/부가정보 폼처럼 큰 블록이 뒤늦게
|
||||
// 레이아웃되면 애니메이션 도중 목표 위치가 밀려 중간에 멈춘다.
|
||||
// 진입 첫 렌더는 즉시(auto) — 히스토리를 복원하며 위에서부터 훑어 내려가는 연출을 막는다.
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
const scroller = scrollRef.current
|
||||
if (!scroller) return
|
||||
const behavior: ScrollBehavior = isFirstRef.current ? 'auto' : 'smooth'
|
||||
isFirstRef.current = false
|
||||
const id = requestAnimationFrame(() => {
|
||||
scroller.scrollTo({ top: scroller.scrollHeight, behavior })
|
||||
})
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [chats, isLoading])
|
||||
|
||||
if (!chats || chats.length === 0) {
|
||||
@ -48,7 +64,6 @@ function ChatList() {
|
||||
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
|
||||
))}
|
||||
{isLoading && <TypingBubble />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,7 +10,8 @@ export function ChatSection() {
|
||||
<MobileStepBar />
|
||||
<ChatMessage />
|
||||
{/* 하단 액션 덱 */}
|
||||
<div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
||||
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
|
||||
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
||||
<UserButton {...userButtonConfig} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -31,7 +31,7 @@ export function MobileDrawer({ open, onClose, side = 'left', title, children }:
|
||||
side === 'left' ? 'left-0' : 'right-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-5">
|
||||
<div className="flex h-14 box-content safe-t shrink-0 items-center justify-between border-b border-border px-5">
|
||||
<span className="text-base font-bold text-neutral-90">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
@ -42,7 +42,7 @@ export function MobileDrawer({ open, onClose, side = 'left', title, children }:
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto safe-b">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@ -160,6 +160,9 @@ export function useChatController(sessionId: string) {
|
||||
useChatInitStore.getState().reset()
|
||||
// init 은 진입 메타 캐시 — 비워 두어 재진입이 항상 최신 status 로 진입 게이트(1301)를 다시 타게 한다.
|
||||
queryClient.removeQueries({ queryKey: chatKeys.init(sessionId) })
|
||||
// messages 는 staleTime:Infinity 라 캐시가 남으면 재진입해도 다시 안 부른다 —
|
||||
// 오프닝 생성 전(협상생성 상태)에 받은 빈 목록이 눌러앉아 채팅이 빈 화면으로 굳는다.
|
||||
queryClient.removeQueries({ queryKey: chatKeys.messages(sessionId) })
|
||||
}
|
||||
}, [sessionId, sendMutate, navigate, queryClient])
|
||||
|
||||
|
||||
@ -162,6 +162,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). 안전영역이 0 인 기기에선 아무 영향 없다.
|
||||
화면 맨 위/맨 아래에 붙는 바에만 붙인다 — 스크롤되는 본문에는 쓰지 않는다. */
|
||||
@layer components {
|
||||
.safe-t { padding-top: env(safe-area-inset-top); }
|
||||
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
|
||||
.safe-x {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
/* 타이포그래피 스케일 (headline / title / body / reject) */
|
||||
@layer components {
|
||||
.headline-3 { font-size: 24px; font-weight: 700; line-height: 30px; letter-spacing: -0.48px; }
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { cn } from '@/lib'
|
||||
|
||||
// safe-t: 노치 기기에서 헤더가 상태바 밑으로 파고들지 않도록 안전영역만큼 키운다(h-[56px] 은 콘텐츠 높이).
|
||||
const HEADER_BASE =
|
||||
'flex w-full h-[56px] items-center bg-white text-foreground border-b border-border'
|
||||
'flex w-full h-[56px] box-content safe-t items-center bg-white text-foreground border-b border-border'
|
||||
|
||||
const HEADER_ALIGN = {
|
||||
left: 'justify-start',
|
||||
|
||||
@ -13,14 +13,14 @@ const SIDEBAR_WIDTH = {
|
||||
} 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-[1024px] bg-background',
|
||||
root: 'flex w-full min-h-[100dvh] max-h-[100dvh] bg-background safe-x',
|
||||
scrollX: 'flex w-full min-h-[100dvh] max-h-[100dvh] overflow-x-auto overflow-y-hidden safe-x',
|
||||
scrollXInner: 'flex w-full min-h-[100dvh] max-h-[100dvh] min-w-[1024px] bg-background',
|
||||
// 좌측 레일은 lg 미만에서 숨기고(드로어로 대체), main 이 전체 폭을 차지한다. 보고서식 풀블리드 + 경계선.
|
||||
sidebar: 'hidden lg:flex flex-col h-screen bg-white border-r border-border',
|
||||
sidebar: 'hidden lg:flex flex-col h-[100dvh] bg-white border-r border-border',
|
||||
logoHeader:
|
||||
'flex w-full h-[56px] pl-[24px] pr-[16px] items-center justify-between shrink-0 border-b border-border',
|
||||
main: 'flex flex-1 flex-col h-screen overflow-hidden',
|
||||
main: 'flex flex-1 flex-col h-[100dvh] overflow-hidden',
|
||||
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden bg-surface',
|
||||
} as const
|
||||
|
||||
|
||||
@ -75,3 +75,14 @@ body {
|
||||
filter: blur(100px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). 안전영역이 0 인 기기에선 아무 영향 없다.
|
||||
화면 맨 위/맨 아래에 붙는 바에만 붙인다 — 스크롤되는 본문에는 쓰지 않는다. */
|
||||
@layer components {
|
||||
.safe-t { padding-top: env(safe-area-inset-top); }
|
||||
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
|
||||
.safe-x {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-40 transition-all duration-300 ${
|
||||
className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${
|
||||
scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent border-b border-transparent"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -15,7 +15,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>NegoData</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -124,7 +124,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex">
|
||||
<div className="min-h-[100dvh] safe-x bg-background text-foreground flex">
|
||||
{/* 토스트는 sonner <Toaster/>(main.tsx)가 전역으로 처리한다 */}
|
||||
|
||||
{/* 모바일 드로어 백드롭 */}
|
||||
@ -273,7 +273,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
||||
)}
|
||||
>
|
||||
{/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */}
|
||||
<header className="h-14 bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20">
|
||||
<header className="h-14 box-content safe-t bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
{/* 모바일 햄버거 */}
|
||||
<Button
|
||||
|
||||
@ -64,3 +64,14 @@ body {
|
||||
55% { opacity: 0.95; transform: rotate(-12deg) scale(0.9); }
|
||||
100% { opacity: 0.75; transform: rotate(-12deg) scale(1); }
|
||||
}
|
||||
|
||||
/* 노치/홈 인디케이터 회피 (viewport-fit=cover 와 짝). 안전영역이 0 인 기기에선 아무 영향 없다.
|
||||
화면 맨 위/맨 아래에 붙는 바에만 붙인다 — 스크롤되는 본문에는 쓰지 않는다. */
|
||||
@layer components {
|
||||
.safe-t { padding-top: env(safe-area-inset-top); }
|
||||
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
|
||||
.safe-x {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user