feat(chat): 협상 유의사항 안내 팝업 2종 이식 + 팝업 숨김 저장 API

KT-NEGOWIZ fe_v2 의 서비스 안내 팝업을 우리 구조로 이식.

- frontend: GuideContent(공용 본문, VAT/배송비는 chat init 메타로 동적 표시)
  + ServiceInfoPopup(채팅 진입 시 자동 — 안내 보지 않기/오늘 하루 보지 않기)
  + ServiceGuidePopup(메뉴 유의사항·이용 가이드 버튼 수동 — X/배경/ESC 닫기)
  + useServiceInfoPopup 훅(서버 상태 + localStorage 오늘 하루)
  + apis/auth 에 popupStatus 쿼리·hidePopup 뮤테이션, 헬프데스크 연락처 반영,
    한국어 단어 잘림 방지(break-keep)
- backend: GET /v1/auth/popup/status, POST /v1/auth/popup/hide
  (authenticate 공통 인증 + supplier_users.hide_service_info 영구 저장), 테스트 4건
- db: supplier_users.hide_service_info 컬럼 — 00-init 테이블 정의 갱신,
  기존 DB 보정은 alters/2026-07-07-supplier-users-hide-service-info.sql 별도 파일
  (기준선 이후 보정 ALTER 는 alters/ 파일로 관리하도록 규칙 변경)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-07 13:41:46 +09:00
parent dc0e78ec73
commit 3c8923c075
21 changed files with 488 additions and 21 deletions

View File

@ -29,6 +29,7 @@ class supplier_users(MAIN_BASE):
last_accessed_at = Column(DateTime(timezone=True), nullable=False) # 마지막 접속 시각
status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive
role = Column(SmallInteger, nullable=False, server_default=text("1")) # 권한: 1=user, 2=manager
hide_service_info = Column(Boolean, nullable=False, server_default=text("false")) # 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부

View File

@ -56,6 +56,14 @@ class IUserCRUD(ABC):
async def update_last_accessed(self, cdb: AsyncSession, su_id) -> ErrorType:
pass
@abstractmethod
async def get_hide_service_info(self, cdb: AsyncSession, su_id) -> Tuple[ErrorType, bool]:
pass
@abstractmethod
async def set_hide_service_info(self, cdb: AsyncSession, su_id) -> ErrorType:
pass
class UserCRUD(IUserCRUD):
async def get_account_by_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, supplier_users]:
@ -195,3 +203,34 @@ class UserCRUD(IUserCRUD):
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def get_hide_service_info(self, cdb: AsyncSession, su_id) -> Tuple[ErrorType, bool]:
# 서비스 안내 팝업 "안내 보지 않기" 여부만 조회한다.
try:
query = (
select(supplier_users.hide_service_info)
.where(supplier_users.su_id == su_id, supplier_users.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_hide_service_info(su_id:{su_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, bool(row_list[0])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def set_hide_service_info(self, cdb: AsyncSession, su_id) -> ErrorType:
# "안내 보지 않기" 는 켜기만 있다(해제 API 없음).
try:
query = (
update(supplier_users)
.where(supplier_users.su_id == su_id, supplier_users.deleted == False) # noqa: E712
.values(hide_service_info=True)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -9,7 +9,18 @@ from router.v1.validator.dependencies import (
security,
)
from services.auth_service import AuthService
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Logout, Res_Me, Res_RefreshToken
from .protocol import (
Req_CreateAccount,
Req_HidePopup,
Req_Login,
Res_CreateAccount,
Res_HidePopup,
Res_Login,
Res_Logout,
Res_Me,
Res_PopupStatus,
Res_RefreshToken,
)
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
@ -54,6 +65,35 @@ async def logout(user_info: UserInfo = Depends(IsValidAccessToken), service: Aut
return RemoveNoneResponse(await service.logout(user_info))
@router.get(
path="/popup/status",
response_model=Res_PopupStatus,
summary="팝업 숨김 상태 조회",
description="유저별 팝업 '안내 보지 않기' 저장 상태를 반환한다. 현재는 service_info(협상 유의사항 안내) 하나.",
)
async def popup_status(
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: AuthService = Depends(),
):
return RemoveNoneResponse(await service.popup_status(user_info, credentials.credentials))
@router.post(
path="/popup/hide",
response_model=Res_HidePopup,
summary="팝업 안내 보지 않기",
description="해당 팝업을 다시 표시하지 않도록 유저에 영구 저장한다. popup_type: service_info",
)
async def hide_popup(
req: Req_HidePopup,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: AuthService = Depends(),
):
return RemoveNoneResponse(await service.hide_popup(user_info, credentials.credentials, req.popup_type))
@router.get(
path="/me",
response_model=Res_Me,

View File

@ -52,3 +52,15 @@ class Res_Me(Res_WebPacketProtocol):
class Res_Logout(Res_WebPacketProtocol):
pass
class Res_PopupStatus(Res_WebPacketProtocol):
service_info: bool = Field(False, description="협상 유의사항(서비스 안내) 팝업 '안내 보지 않기' 여부")
class Req_HidePopup(AuthProtocol):
popup_type: str = Field("", description="숨길 팝업 종류: service_info")
class Res_HidePopup(Res_WebPacketProtocol):
pass

View File

@ -10,7 +10,15 @@ from common.models.gmodel import UserInfo
from common.utils.gtime import GTime
from config.server_configs import jwt_token_config
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import Res_CreateAccount, Res_Login, Res_Logout, Res_Me, Res_RefreshToken
from router.v1.auth.protocol import (
Res_CreateAccount,
Res_HidePopup,
Res_Login,
Res_Logout,
Res_Me,
Res_PopupStatus,
Res_RefreshToken,
)
from router.v1.validator.dependencies import CreateAccessToken, CreateRefreshToken, GetHashedPW, VerifyPW
@ -244,6 +252,45 @@ class AuthService:
res.role = info.role
return res
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
# 유저별 팝업 숨김 상태 조회. 현재는 서비스 안내(service_info) 팝업 하나만 관리한다.
res = Res_PopupStatus()
err_type, info = await self.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type, hidden = await DB_SESSION_MNG.execute_lambda(
supplier_users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_hide_service_info(s, uuid.UUID(info.su_id)),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.service_info = hidden
return res
async def hide_popup(self, user_info: UserInfo, access_token: str, popup_type: str) -> Res_HidePopup:
# "안내 보지 않기" 영구 저장. 팝업 종류가 늘면 popup_type 분기를 추가한다.
res = Res_HidePopup()
if popup_type != "service_info":
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
err_type, info = await self.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type = await DB_SESSION_MNG.execute_lambda_run(
[supplier_users.DBType()],
[lambda s: self.user_crud.set_hide_service_info(s, uuid.UUID(info.su_id))],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
async def logout(self, user_info: UserInfo) -> Res_Logout:
# 해당 유저의 저장 토큰(access/refresh)을 모두 삭제 → 이후 보호 요청·재발급이 차단된다.
res = Res_Logout()

View File

@ -322,3 +322,52 @@ async def test_relogin_invalidates_previous_access(client, account_seed):
# 새 access → 정상
r_new = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})
assert r_new.json()["result"]["success"] is True
# ---- 팝업 숨김 (popup/status, popup/hide) --------------------------------------
async def test_popup_status_default_false(client, account_seed):
# 신규 유저는 숨김 상태가 아니다.
access = (await _login(client)).json()["access_token"]
r = await client.get("/v1/auth/popup/status", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is True
assert body["service_info"] is False
async def test_popup_hide_persists(client, account_seed):
# hide 후 status 가 True 로 바뀌고, 재로그인해도 유지된다(유저 행에 영구 저장).
access = (await _login(client)).json()["access_token"]
r = await client.post(
"/v1/auth/popup/hide",
json={"popup_type": "service_info"},
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 200
assert r.json()["result"]["success"] is True
r2 = await client.get("/v1/auth/popup/status", headers={"Authorization": f"Bearer {access}"})
assert r2.json()["service_info"] is True
# 재로그인(토큰 교체) 후에도 유지
new_access = (await _login(client)).json()["access_token"]
r3 = await client.get("/v1/auth/popup/status", headers={"Authorization": f"Bearer {new_access}"})
assert r3.json()["service_info"] is True
async def test_popup_hide_invalid_type(client, account_seed):
access = (await _login(client)).json()["access_token"]
r = await client.post(
"/v1/auth/popup/hide",
json={"popup_type": "unknown_popup"},
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is False
assert body["result"]["code"] == 101 # INVALID_REQUEST_DATA
async def test_popup_status_without_token(client):
r = await client.get("/v1/auth/popup/status")
assert r.status_code in (401, 403)

View File

@ -4,10 +4,13 @@ import { http } from '@/apis/http'
import type {
CreateAccountRequest,
CreateAccountResponse,
HidePopupRequest,
HidePopupResponse,
LoginRequest,
LoginResponse,
LogoutResponse,
MeResponse,
PopupStatusResponse,
} from './auth.type'
export const authApi = {
@ -34,4 +37,16 @@ export const authApi = {
const res = await http.post<LogoutResponse>('/v1/auth/logout')
return res.data
},
/** GET /v1/auth/popup/status — 유저별 팝업 '안내 보지 않기' 저장 상태 */
popupStatus: async (): Promise<PopupStatusResponse> => {
const res = await http.get<PopupStatusResponse>('/v1/auth/popup/status')
return res.data
},
/** POST /v1/auth/popup/hide — 해당 팝업을 다시 표시하지 않도록 영구 저장 */
hidePopup: async (body: HidePopupRequest): Promise<HidePopupResponse> => {
const res = await http.post<HidePopupResponse>('/v1/auth/popup/hide', body)
return res.data
},
}

View File

@ -2,4 +2,5 @@
export const authKeys = {
all: ['auth'] as const,
me: () => [...authKeys.all, 'me'] as const,
popupStatus: () => [...authKeys.all, 'popupStatus'] as const,
}

View File

@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tokenStorage } from '@/apis/tokenStorage'
import { authApi } from './auth.api'
import { authKeys } from './auth.keys'
import type { CreateAccountRequest, LoginResponse } from './auth.type'
import type { CreateAccountRequest, LoginResponse, PopupType } from './auth.type'
/** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */
export interface LoginParams {
@ -53,3 +53,14 @@ export function useCreateAccountMutation() {
mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body),
})
}
/** 팝업 '안내 보지 않기' 영구 저장. 성공 시 상태 캐시를 무효화한다. */
export function useHidePopupMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (popupType: PopupType) => authApi.hidePopup({ popup_type: popupType }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.popupStatus() })
},
})
}

View File

@ -18,3 +18,13 @@ export function useMeQuery() {
select: toAuthUser,
})
}
/** 유저별 팝업 '안내 보지 않기' 저장 상태 조회 */
export function usePopupStatusQuery() {
return useQuery({
queryKey: authKeys.popupStatus(),
queryFn: authApi.popupStatus,
enabled: tokenStorage.hasToken(),
staleTime: 5 * 60 * 1000, // 5분
})
}

View File

@ -69,6 +69,23 @@ export interface LogoutResponse {
result: ApiResult
}
// --- 팝업 숨김 (GET /v1/auth/popup/status, POST /v1/auth/popup/hide) --------
/** '안내 보지 않기' 를 서버에 저장하는 팝업 종류 */
export type PopupType = 'service_info'
export interface PopupStatusResponse {
result: ApiResult
service_info: boolean
}
export interface HidePopupRequest {
popup_type: PopupType
}
export interface HidePopupResponse {
result: ApiResult
}
/** 앱에서 다루기 편한 현재 유저 형태 (MeResponse 에서 파생) */
export interface AuthUser {
suId: string

View File

@ -1,11 +1,12 @@
// 인증 API 모듈 공개 표면.
export { authApi } from './auth.api'
export { authKeys } from './auth.keys'
export { useMeQuery } from './auth.queries'
export { useMeQuery, usePopupStatusQuery } from './auth.queries'
export {
useLoginMutation,
useLogoutMutation,
useCreateAccountMutation,
useHidePopupMutation,
type LoginParams,
} from './auth.mutations'
export * from './auth.type'

View File

@ -1,22 +1,28 @@
import { useState } from 'react'
import { interactive, cn } from '@/lib'
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
// 헬프데스크 (TODO: 이용가이드 팝업 연동, 연락처는 추후 설정값으로 교체)
// 헬프데스크 — 이용 가이드 버튼 클릭 시 안내 팝업을 연다.
export function Contact() {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="flex flex-col w-full pl-[24px] pb-[24px]">
<div className="flex flex-col w-full gap-2">
<div className="title-5 text-neutral-80">헬프 데스크</div>
<button
type="button"
onClick={() => setIsOpen(true)}
className={cn('body-5 text-neutral-80 bg-neutral-40 rounded-[8px] px-[12px] py-[6px] w-fit', interactive)}
>
이용 가이드
</button>
<div className="flex flex-col gap-1">
<div className="body-5 text-neutral-60">-</div>
<div className="body-5 text-neutral-60">-</div>
<div className="body-5 text-neutral-60">010-0000-0000</div>
<div className="body-5 text-neutral-60">o2odev@o2o.kr</div>
</div>
</div>
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
</div>
)
}

View File

@ -1,18 +1,26 @@
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { interactive, cn } from '@/lib'
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
// 유의사항 및 이용방법 (TODO: 이용가이드 팝업 연동)
// 유의사항 및 이용방법 — 클릭 시 안내 팝업을 연다.
export function Guide() {
const [isOpen, setIsOpen] = useState(false)
return (
<button
type="button"
className={cn(
'flex w-full bg-white rounded-[28px] py-[20px] pl-[24px] pr-[16px] justify-between items-center text-left',
interactive,
)}
>
<span className="menu-title text-neutral-80 break-keep">유의사항 및 이용방법</span>
<ChevronRight size={20} className="text-neutral-70" />
</button>
<>
<button
type="button"
onClick={() => setIsOpen(true)}
className={cn(
'flex w-full bg-white rounded-[28px] py-[20px] pl-[24px] pr-[16px] justify-between items-center text-left',
interactive,
)}
>
<span className="menu-title text-neutral-80 break-keep">유의사항 및 이용방법</span>
<ChevronRight size={20} className="text-neutral-70" />
</button>
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
</>
)
}

View File

@ -0,0 +1,91 @@
import type { ReactNode } from 'react'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문.
// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다.
// break-keep: 한국어 단어가 중간에서 잘려 줄바꿈되지 않도록 한다.
const textStyle = 'text-[17px] font-normal leading-[150%] tracking-[-0.34px] text-neutral-90 break-keep'
function Bullet({ children }: { children: ReactNode }) {
return (
<div className="flex items-start py-[6px]">
<p className="ml-[8px] mr-[6px] flex-shrink-0">•</p>
<div className={`${textStyle} flex-1`}>{children}</div>
</div>
)
}
export function GuideContent() {
const { item_vat_yn, item_delivery_fee_yn } = useChatInitStore()
// init 미로드/값 없음 → 보수적 기본값 (KT-NEGOWIZ 와 동일한 폴백 규칙)
const vat = item_vat_yn || 'VAT별도'
const deliveryFee = item_delivery_fee_yn || '배송비별도'
return (
<div className="flex w-full flex-col">
<div className="headline-3 mb-10 break-keep text-center text-neutral-90">
협상 유의 사항 및 서비스 이용 방법 안내
</div>
<div className="flex flex-col items-start">
<div className={`${textStyle} mb-2`}>
협상과 관련하여 다음 사항을 참고하여 견적에 참여해 주시기 바랍니다.
</div>
<Bullet>
협상 개시는&nbsp;
<span className="font-bold">Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작</span>
됩니다.
</Bullet>
<Bullet>
부여된 협상 시간에&nbsp;
<span className="font-bold">
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
</span>
될 수 있습니다.
</Bullet>
<Bullet>
협상에 입력되는 모든 가격은&nbsp;
<span className="font-bold text-negative">
{vat} 및 {deliveryFee}
</span>
&nbsp;기준이며,&nbsp;
<span className="font-bold">
할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산
</span>
되어 제시됩니다.
</Bullet>
<Bullet>
본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서
얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를
전제로 진행됩니다.
</Bullet>
<Bullet>
협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기
바랍니다.
</Bullet>
<Bullet>안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.</Bullet>
</div>
</div>
)
}
// 하단 문의 안내 박스
export function GuideContactBox() {
return (
<div className="flex w-full flex-col items-center gap-2 rounded-[16px] bg-neutral-10 p-6">
<div className={`${textStyle} text-center`}>
기타 협상과정에서 궁금한 사항이나 문의하실 사항은 아래의 연락처로 상담을 부탁드립니다.
</div>
{/* textStyle 의 font-normal 과 같은 요소에서 충돌하지 않도록 bold 는 별도 조합으로 준다 */}
<div className="break-keep text-center text-[17px] font-bold leading-[150%] tracking-[-0.34px] text-neutral-90">
헬프데스크 010-0000-0000, 이메일: o2odev@o2o.kr
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { cn, interactive } from '@/lib'
import { GuideContent, GuideContactBox } from './GuideContent'
// 유의사항 및 이용방법 팝업 (메뉴 버튼으로 여는 수동형). X 버튼 · 배경 클릭 · ESC 로 닫는다.
export function ServiceGuidePopup({ onClose }: { onClose: () => void }) {
return (
<Modal onClose={onClose}>
<div className="flex max-h-[90vh] w-full max-w-[1000px] flex-col items-end gap-6 overflow-y-auto rounded-3xl bg-white px-12 pb-[56px] pt-8">
<button
type="button"
aria-label="닫기"
onClick={onClose}
className={cn(
'flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-neutral-10',
interactive,
)}
>
<X size={20} className="text-neutral-80" />
</button>
<div className="flex w-full flex-col gap-6 px-[40px]">
<GuideContent />
<GuideContactBox />
</div>
</div>
</Modal>
)
}

View File

@ -0,0 +1,40 @@
import { cn, interactive } from '@/lib'
import { GuideContent, GuideContactBox } from './GuideContent'
interface ServiceInfoPopupProps {
isOpen: boolean
/** 서버에 영구 저장 — 이후 어떤 기기에서도 다시 뜨지 않음 */
onNeverShowAgain: () => void
/** 오늘 하루 보지 않기 (localStorage) */
onCloseToday: () => void
}
const buttonStyle = cn(
'flex h-[50px] w-[168px] items-center justify-center rounded-full',
'border border-neutral-30 bg-white title-5 text-neutral-70',
interactive,
)
// 채팅 진입 시 자동 표시되는 서비스 안내 팝업. 버튼 2개로만 닫는다(배경 클릭/ESC 없음).
export function ServiceInfoPopup({ isOpen, onNeverShowAgain, onCloseToday }: ServiceInfoPopupProps) {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.40)] p-4">
<div className="flex max-h-[90vh] w-full max-w-[1000px] flex-col overflow-y-auto rounded-[24px] bg-white px-[80px] pb-[48px] pt-[56px]">
<div className="mb-6 flex w-full flex-col gap-5">
<GuideContent />
<GuideContactBox />
</div>
<div className="flex w-full justify-center gap-3">
<button type="button" className={buttonStyle} onClick={onNeverShowAgain}>
안내 보지 않기
</button>
<button type="button" className={buttonStyle} onClick={onCloseToday}>
오늘 하루 보지 않기
</button>
</div>
</div>
</div>
)
}

View File

@ -1,13 +1,16 @@
import { Loader2 } from 'lucide-react'
import { ErrorPage } from '@/components'
import { useChatController } from '@/features/chat/hooks/useChatController'
import { useServiceInfoPopup } from '@/features/chat/hooks/useServiceInfoPopup'
import { ChatSection } from '@/features/chat/components/ChatSection'
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
import { ServiceInfoPopup } from '@/features/chat/components/popup/ServiceInfoPopup'
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
// 진입 로드(init/messages) 상태를 직접 그린다: 로딩 → 스피너, 실패(서버/네트워크) → ErrorPage(재시도).
export function ChatContainer({ sessionId }: { sessionId: string }) {
const { isInitLoading, initError, refetchInit } = useChatController(sessionId)
const serviceInfo = useServiceInfoPopup()
if (isInitLoading) {
return (
@ -27,6 +30,11 @@ export function ChatContainer({ sessionId }: { sessionId: string }) {
return (
<div className="flex flex-1 min-h-0 w-full">
<ServiceInfoPopup
isOpen={serviceInfo.isOpen}
onNeverShowAgain={serviceInfo.neverShowAgain}
onCloseToday={serviceInfo.closeToday}
/>
<ChatSection />
<MenuSection />
</div>

View File

@ -0,0 +1,33 @@
import { useState } from 'react'
import { useHidePopupMutation, usePopupStatusQuery } from '@/apis/auth'
// 채팅 진입 시 자동 표시되는 서비스 안내 팝업의 노출 결정.
// - '안내 보지 않기' : 서버(supplier_users.hide_service_info) 영구 저장
// - '오늘 하루 보지 않기': localStorage 에 오늘 날짜 저장 (브라우저 단위)
const STORAGE_KEY = 'serviceInfoClosedDate'
function isClosedToday(): boolean {
return localStorage.getItem(STORAGE_KEY) === new Date().toDateString()
}
export function useServiceInfoPopup() {
const { data } = usePopupStatusQuery()
const { mutate: hidePopup } = useHidePopupMutation()
const [closed, setClosed] = useState(false)
// 서버 상태 로드 전에는 열지 않는다 (숨김 유저에게 깜빡임 방지).
const isOpen = data?.result.success === true && !data.service_info && !closed && !isClosedToday()
const neverShowAgain = () => {
setClosed(true)
hidePopup('service_info')
localStorage.removeItem(STORAGE_KEY) // 영구 숨김이 우선이므로 오늘 하루 기록은 정리
}
const closeToday = () => {
setClosed(true)
localStorage.setItem(STORAGE_KEY, new Date().toDateString())
}
return { isOpen, neverShowAgain, closeToday }
}

View File

@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS supplier.supplier_users (
last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user(유저), 2=manager(매니저)
hide_service_info BOOLEAN NOT NULL DEFAULT FALSE, -- 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
@ -653,9 +654,9 @@ ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC;
-- 기존 DB 보정(ALTER) — 재실행 시 기존 DB 를 최신 스키마로 맞춘다
-- ============================================================
-- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은
-- 멱등 ALTER 로 여기에 함께 둔다. 신규 DB 에는 전부 no-op.
-- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할).
-- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
-- 멱등 ALTER 로 보정한다. 신규 DB 에는 전부 no-op.
-- 기준선(2026-07-07 main 스키마)까지의 보정은 이 섹션에 있고, 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
-- 기준선 이후의 새 스키마 변경은 위 테이블 정의를 갱신하고, 보정 ALTER 는 alters/ 아래 별도 파일로 만들어 적용한다.
-- [2026-07-07] 협상 카드: script 길이 제한 해제(TEXT) + 톤·전략 분류 컬럼
ALTER TABLE card.nego_cards ALTER COLUMN script TYPE TEXT;

View File

@ -0,0 +1,8 @@
-- 기존 DB 보정 — supplier_users 에 서비스 안내 팝업 "안내 보지 않기" 저장 컬럼 추가.
-- 신규 DB 는 00-init.sql 의 테이블 정의에 이미 포함되어 있어 이 파일이 필요 없다(멱등이라 실행해도 무해).
-- 적용: psql -h <host> -p <port> -U <user> -f postgres-init/alters/2026-07-07-supplier-users-hide-service-info.sql
\connect negosium_db
ALTER TABLE supplier.supplier_users
ADD COLUMN IF NOT EXISTS hide_service_info BOOLEAN NOT NULL DEFAULT FALSE; -- 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부