diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py
index 0dabae4..53a51f8 100644
--- a/backend/services/chat_service.py
+++ b/backend/services/chat_service.py
@@ -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)
diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py
index 5f6c1b3..f86bc3f 100644
--- a/backend/services/negotiation_service.py
+++ b/backend/services/negotiation_service.py
@@ -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) 을 반환한다.
diff --git a/frontend/index.html b/frontend/index.html
index c5bc6af..72e32c9 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,7 @@
-
+
AI 가격 협상 솔루션
diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx
index a046996..3141b53 100644
--- a/frontend/src/features/chat/components/ChatMessage.tsx
+++ b/frontend/src/features/chat/components/ChatMessage.tsx
@@ -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(null)
return (
- {/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
-
-
+ {/* lg:pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추는 값 — 그 카드가 없는 lg 미만에선
+ 스텝바 바로 아래 여백으로만 남아 첫 메시지가 위로 안 붙으므로 뺀다.
+ 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
+
+
)
}
-function ChatList() {
- const bottomRef = useRef(null)
+function ChatList({ scrollRef }: { scrollRef: RefObject }) {
+ 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() {
))}
{isLoading && }
-
)
}
diff --git a/frontend/src/features/chat/components/ChatSection.tsx b/frontend/src/features/chat/components/ChatSection.tsx
index cbad604..b566575 100644
--- a/frontend/src/features/chat/components/ChatSection.tsx
+++ b/frontend/src/features/chat/components/ChatSection.tsx
@@ -10,7 +10,8 @@ export function ChatSection() {
{/* 하단 액션 덱 */}
-
+ {/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
+
diff --git a/frontend/src/features/chat/components/MobileDrawer.tsx b/frontend/src/features/chat/components/MobileDrawer.tsx
index 8707892..dc0306c 100644
--- a/frontend/src/features/chat/components/MobileDrawer.tsx
+++ b/frontend/src/features/chat/components/MobileDrawer.tsx
@@ -31,7 +31,7 @@ export function MobileDrawer({ open, onClose, side = 'left', title, children }:
side === 'left' ? 'left-0' : 'right-0',
)}
>
-
+
{title}
-
{children}
+
{children}
,
document.body,
diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts
index 91c37d1..ad0aaea 100644
--- a/frontend/src/features/chat/hooks/useChatController.ts
+++ b/frontend/src/features/chat/hooks/useChatController.ts
@@ -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])
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a0b9eeb..382dc7d 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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; }
diff --git a/frontend/src/layouts/MainHeaderBar.tsx b/frontend/src/layouts/MainHeaderBar.tsx
index 08ab364..e7b1e1b 100644
--- a/frontend/src/layouts/MainHeaderBar.tsx
+++ b/frontend/src/layouts/MainHeaderBar.tsx
@@ -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',
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index ee25df1..0710b29 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -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
diff --git a/landing/app/app.css b/landing/app/app.css
index 14df723..55530ca 100644
--- a/landing/app/app.css
+++ b/landing/app/app.css
@@ -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);
+ }
+}
diff --git a/landing/app/components/sections/header.tsx b/landing/app/components/sections/header.tsx
index a38d845..833101f 100644
--- a/landing/app/components/sections/header.tsx
+++ b/landing/app/components/sections/header.tsx
@@ -17,7 +17,7 @@ export function Header() {
return (
diff --git a/landing/app/root.tsx b/landing/app/root.tsx
index be2e7ce..0a50250 100644
--- a/landing/app/root.tsx
+++ b/landing/app/root.tsx
@@ -15,7 +15,7 @@ export function Layout({ children }: { children: ReactNode }) {
-
+
diff --git a/negodata/front/index.html b/negodata/front/index.html
index 47bc097..6ff6371 100644
--- a/negodata/front/index.html
+++ b/negodata/front/index.html
@@ -2,7 +2,7 @@
-
+
NegoData
diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx
index e3ab48a..82075f3 100644
--- a/negodata/front/src/components/layout/Layout.tsx
+++ b/negodata/front/src/components/layout/Layout.tsx
@@ -124,7 +124,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
};
return (
-