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:
parent
dc0e78ec73
commit
3c8923c075
@ -29,6 +29,7 @@ class supplier_users(MAIN_BASE):
|
|||||||
last_accessed_at = Column(DateTime(timezone=True), nullable=False) # 마지막 접속 시각
|
last_accessed_at = Column(DateTime(timezone=True), nullable=False) # 마지막 접속 시각
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive
|
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
|
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)
|
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 시 자동 갱신)
|
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")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|||||||
@ -56,6 +56,14 @@ class IUserCRUD(ABC):
|
|||||||
async def update_last_accessed(self, cdb: AsyncSession, su_id) -> ErrorType:
|
async def update_last_accessed(self, cdb: AsyncSession, su_id) -> ErrorType:
|
||||||
pass
|
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):
|
class UserCRUD(IUserCRUD):
|
||||||
async def get_account_by_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, supplier_users]:
|
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:
|
except Exception as ex:
|
||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
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
|
||||||
|
|||||||
@ -9,7 +9,18 @@ from router.v1.validator.dependencies import (
|
|||||||
security,
|
security,
|
||||||
)
|
)
|
||||||
from services.auth_service import AuthService
|
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 반환만 담당.
|
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
||||||
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
|
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))
|
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(
|
@router.get(
|
||||||
path="/me",
|
path="/me",
|
||||||
response_model=Res_Me,
|
response_model=Res_Me,
|
||||||
|
|||||||
@ -52,3 +52,15 @@ class Res_Me(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_Logout(Res_WebPacketProtocol):
|
class Res_Logout(Res_WebPacketProtocol):
|
||||||
pass
|
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
|
||||||
|
|||||||
@ -10,7 +10,15 @@ from common.models.gmodel import UserInfo
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from config.server_configs import jwt_token_config
|
from config.server_configs import jwt_token_config
|
||||||
from crud.user_crud import IUserCRUD, UserCRUD
|
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
|
from router.v1.validator.dependencies import CreateAccessToken, CreateRefreshToken, GetHashedPW, VerifyPW
|
||||||
|
|
||||||
|
|
||||||
@ -244,6 +252,45 @@ class AuthService:
|
|||||||
res.role = info.role
|
res.role = info.role
|
||||||
return res
|
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:
|
async def logout(self, user_info: UserInfo) -> Res_Logout:
|
||||||
# 해당 유저의 저장 토큰(access/refresh)을 모두 삭제 → 이후 보호 요청·재발급이 차단된다.
|
# 해당 유저의 저장 토큰(access/refresh)을 모두 삭제 → 이후 보호 요청·재발급이 차단된다.
|
||||||
res = Res_Logout()
|
res = Res_Logout()
|
||||||
|
|||||||
@ -322,3 +322,52 @@ async def test_relogin_invalidates_previous_access(client, account_seed):
|
|||||||
# 새 access → 정상
|
# 새 access → 정상
|
||||||
r_new = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})
|
r_new = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})
|
||||||
assert r_new.json()["result"]["success"] is True
|
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)
|
||||||
|
|||||||
@ -4,10 +4,13 @@ import { http } from '@/apis/http'
|
|||||||
import type {
|
import type {
|
||||||
CreateAccountRequest,
|
CreateAccountRequest,
|
||||||
CreateAccountResponse,
|
CreateAccountResponse,
|
||||||
|
HidePopupRequest,
|
||||||
|
HidePopupResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
MeResponse,
|
MeResponse,
|
||||||
|
PopupStatusResponse,
|
||||||
} from './auth.type'
|
} from './auth.type'
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
@ -34,4 +37,16 @@ export const authApi = {
|
|||||||
const res = await http.post<LogoutResponse>('/v1/auth/logout')
|
const res = await http.post<LogoutResponse>('/v1/auth/logout')
|
||||||
return res.data
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,4 +2,5 @@
|
|||||||
export const authKeys = {
|
export const authKeys = {
|
||||||
all: ['auth'] as const,
|
all: ['auth'] as const,
|
||||||
me: () => [...authKeys.all, 'me'] as const,
|
me: () => [...authKeys.all, 'me'] as const,
|
||||||
|
popupStatus: () => [...authKeys.all, 'popupStatus'] as const,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { tokenStorage } from '@/apis/tokenStorage'
|
import { tokenStorage } from '@/apis/tokenStorage'
|
||||||
import { authApi } from './auth.api'
|
import { authApi } from './auth.api'
|
||||||
import { authKeys } from './auth.keys'
|
import { authKeys } from './auth.keys'
|
||||||
import type { CreateAccountRequest, LoginResponse } from './auth.type'
|
import type { CreateAccountRequest, LoginResponse, PopupType } from './auth.type'
|
||||||
|
|
||||||
/** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */
|
/** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */
|
||||||
export interface LoginParams {
|
export interface LoginParams {
|
||||||
@ -53,3 +53,14 @@ export function useCreateAccountMutation() {
|
|||||||
mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body),
|
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() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -18,3 +18,13 @@ export function useMeQuery() {
|
|||||||
select: toAuthUser,
|
select: toAuthUser,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 유저별 팝업 '안내 보지 않기' 저장 상태 조회 */
|
||||||
|
export function usePopupStatusQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: authKeys.popupStatus(),
|
||||||
|
queryFn: authApi.popupStatus,
|
||||||
|
enabled: tokenStorage.hasToken(),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5분
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -69,6 +69,23 @@ export interface LogoutResponse {
|
|||||||
result: ApiResult
|
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 에서 파생) */
|
/** 앱에서 다루기 편한 현재 유저 형태 (MeResponse 에서 파생) */
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
suId: string
|
suId: string
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
// 인증 API 모듈 공개 표면.
|
// 인증 API 모듈 공개 표면.
|
||||||
export { authApi } from './auth.api'
|
export { authApi } from './auth.api'
|
||||||
export { authKeys } from './auth.keys'
|
export { authKeys } from './auth.keys'
|
||||||
export { useMeQuery } from './auth.queries'
|
export { useMeQuery, usePopupStatusQuery } from './auth.queries'
|
||||||
export {
|
export {
|
||||||
useLoginMutation,
|
useLoginMutation,
|
||||||
useLogoutMutation,
|
useLogoutMutation,
|
||||||
useCreateAccountMutation,
|
useCreateAccountMutation,
|
||||||
|
useHidePopupMutation,
|
||||||
type LoginParams,
|
type LoginParams,
|
||||||
} from './auth.mutations'
|
} from './auth.mutations'
|
||||||
export * from './auth.type'
|
export * from './auth.type'
|
||||||
|
|||||||
@ -1,22 +1,28 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { interactive, cn } from '@/lib'
|
import { interactive, cn } from '@/lib'
|
||||||
|
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
|
||||||
|
|
||||||
// 헬프데스크 (TODO: 이용가이드 팝업 연동, 연락처는 추후 설정값으로 교체)
|
// 헬프데스크 — 이용 가이드 버튼 클릭 시 안내 팝업을 연다.
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full pl-[24px] pb-[24px]">
|
<div className="flex flex-col w-full pl-[24px] pb-[24px]">
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
<div className="title-5 text-neutral-80">헬프 데스크</div>
|
<div className="title-5 text-neutral-80">헬프 데스크</div>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
className={cn('body-5 text-neutral-80 bg-neutral-40 rounded-[8px] px-[12px] py-[6px] w-fit', interactive)}
|
||||||
>
|
>
|
||||||
이용 가이드
|
이용 가이드
|
||||||
</button>
|
</button>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<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">-</div>
|
<div className="body-5 text-neutral-60">o2odev@o2o.kr</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,26 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { interactive, cn } from '@/lib'
|
import { interactive, cn } from '@/lib'
|
||||||
|
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
|
||||||
|
|
||||||
// 유의사항 및 이용방법 (TODO: 이용가이드 팝업 연동)
|
// 유의사항 및 이용방법 — 클릭 시 안내 팝업을 연다.
|
||||||
export function Guide() {
|
export function Guide() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'flex w-full bg-white rounded-[28px] py-[20px] pl-[24px] pr-[16px] justify-between items-center text-left',
|
onClick={() => setIsOpen(true)}
|
||||||
interactive,
|
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>
|
<span className="menu-title text-neutral-80 break-keep">유의사항 및 이용방법</span>
|
||||||
|
<ChevronRight size={20} className="text-neutral-70" />
|
||||||
|
</button>
|
||||||
|
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
91
frontend/src/features/chat/components/popup/GuideContent.tsx
Normal file
91
frontend/src/features/chat/components/popup/GuideContent.tsx
Normal 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>
|
||||||
|
협상 개시는
|
||||||
|
<span className="font-bold">Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작</span>
|
||||||
|
됩니다.
|
||||||
|
</Bullet>
|
||||||
|
|
||||||
|
<Bullet>
|
||||||
|
부여된 협상 시간에
|
||||||
|
<span className="font-bold">
|
||||||
|
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
|
||||||
|
</span>
|
||||||
|
될 수 있습니다.
|
||||||
|
</Bullet>
|
||||||
|
|
||||||
|
<Bullet>
|
||||||
|
협상에 입력되는 모든 가격은
|
||||||
|
<span className="font-bold text-negative">
|
||||||
|
{vat} 및 {deliveryFee}
|
||||||
|
</span>
|
||||||
|
기준이며,
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,13 +1,16 @@
|
|||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { ErrorPage } from '@/components'
|
import { ErrorPage } from '@/components'
|
||||||
import { useChatController } from '@/features/chat/hooks/useChatController'
|
import { useChatController } from '@/features/chat/hooks/useChatController'
|
||||||
|
import { useServiceInfoPopup } from '@/features/chat/hooks/useServiceInfoPopup'
|
||||||
import { ChatSection } from '@/features/chat/components/ChatSection'
|
import { ChatSection } from '@/features/chat/components/ChatSection'
|
||||||
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
|
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
|
||||||
|
import { ServiceInfoPopup } from '@/features/chat/components/popup/ServiceInfoPopup'
|
||||||
|
|
||||||
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
|
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
|
||||||
// 진입 로드(init/messages) 상태를 직접 그린다: 로딩 → 스피너, 실패(서버/네트워크) → ErrorPage(재시도).
|
// 진입 로드(init/messages) 상태를 직접 그린다: 로딩 → 스피너, 실패(서버/네트워크) → ErrorPage(재시도).
|
||||||
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
||||||
const { isInitLoading, initError, refetchInit } = useChatController(sessionId)
|
const { isInitLoading, initError, refetchInit } = useChatController(sessionId)
|
||||||
|
const serviceInfo = useServiceInfoPopup()
|
||||||
|
|
||||||
if (isInitLoading) {
|
if (isInitLoading) {
|
||||||
return (
|
return (
|
||||||
@ -27,6 +30,11 @@ export function ChatContainer({ sessionId }: { sessionId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 w-full">
|
<div className="flex flex-1 min-h-0 w-full">
|
||||||
|
<ServiceInfoPopup
|
||||||
|
isOpen={serviceInfo.isOpen}
|
||||||
|
onNeverShowAgain={serviceInfo.neverShowAgain}
|
||||||
|
onCloseToday={serviceInfo.closeToday}
|
||||||
|
/>
|
||||||
<ChatSection />
|
<ChatSection />
|
||||||
<MenuSection />
|
<MenuSection />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
33
frontend/src/features/chat/hooks/useServiceInfoPopup.ts
Normal file
33
frontend/src/features/chat/hooks/useServiceInfoPopup.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS supplier.supplier_users (
|
|||||||
last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각
|
last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
|
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
|
||||||
role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user(유저), 2=manager(매니저)
|
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)
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
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 를 최신 스키마로 맞춘다
|
-- 기존 DB 보정(ALTER) — 재실행 시 기존 DB 를 최신 스키마로 맞춘다
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은
|
-- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은
|
||||||
-- 멱등 ALTER 로 여기에 함께 둔다. 신규 DB 에는 전부 no-op.
|
-- 멱등 ALTER 로 보정한다. 신규 DB 에는 전부 no-op.
|
||||||
-- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할).
|
-- 기준선(2026-07-07 main 스키마)까지의 보정은 이 섹션에 있고, 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
|
||||||
-- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
|
-- 기준선 이후의 새 스키마 변경은 위 테이블 정의를 갱신하고, 보정 ALTER 는 alters/ 아래 별도 파일로 만들어 적용한다.
|
||||||
|
|
||||||
-- [2026-07-07] 협상 카드: script 길이 제한 해제(TEXT) + 톤·전략 분류 컬럼
|
-- [2026-07-07] 협상 카드: script 길이 제한 해제(TEXT) + 톤·전략 분류 컬럼
|
||||||
ALTER TABLE card.nego_cards ALTER COLUMN script TYPE TEXT;
|
ALTER TABLE card.nego_cards ALTER COLUMN script TYPE TEXT;
|
||||||
|
|||||||
@ -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; -- 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부
|
||||||
Loading…
Reference in New Issue
Block a user