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 ( -
+
{/* 토스트는 sonner (main.tsx)가 전역으로 처리한다 */} {/* 모바일 드로어 백드롭 */} @@ -273,7 +273,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay )} > {/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */} -
+
{/* 모바일 햄버거 */}